1use crate::utils::fast_hash;
2use crate::utils::regex_cache::{escape_regex, get_cached_regex};
3
4use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
5use crate::utils::frontmatter_values;
6use crate::utils::range_utils::byte_to_char_count;
7use std::collections::{HashMap, HashSet};
8use std::sync::{Arc, Mutex};
9
10mod md044_config;
11pub(super) use md044_config::MD044Config;
12
13type WarningPosition = (usize, usize, String, usize); fn is_inline_config_comment(trimmed: &str) -> bool {
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: HashMap<String, usize>,
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 Self::from_config_struct(config)
111 }
112
113 fn ascii_normalize(s: &str) -> String {
115 s.replace(['é', 'è', 'ê', 'ë'], "e")
116 .replace(['à', 'á', 'â', 'ä', 'ã', 'å'], "a")
117 .replace(['ï', 'î', 'í', 'ì'], "i")
118 .replace(['ü', 'ú', 'ù', 'û'], "u")
119 .replace(['ö', 'ó', 'ò', 'ô', 'õ'], "o")
120 .replace('ñ', "n")
121 .replace('ç', "c")
122 }
123
124 pub fn from_config_struct(config: MD044Config) -> Self {
125 let combined_pattern = Self::create_combined_pattern(&config);
126 let name_variants = Self::build_name_variants(&config);
127 let ignore_fields = config
128 .ignore_frontmatter_fields
129 .iter()
130 .flatten()
131 .map(|f| f.to_lowercase())
132 .collect();
133 Self {
134 config,
135 combined_pattern,
136 name_variants,
137 ignore_fields,
138 content_cache: Arc::new(Mutex::new(HashMap::new())),
139 }
140 }
141
142 fn create_combined_pattern(config: &MD044Config) -> Option<String> {
144 if config.names.is_empty() {
145 return None;
146 }
147
148 let mut patterns: Vec<String> = config
150 .names
151 .iter()
152 .flat_map(|name| {
153 let mut variations = vec![];
154 let lower_name = name.to_lowercase();
155
156 variations.push(escape_regex(&lower_name));
158
159 let lower_name_no_dots = lower_name.replace('.', "");
161 if lower_name != lower_name_no_dots {
162 variations.push(escape_regex(&lower_name_no_dots));
163 }
164
165 let ascii_normalized = Self::ascii_normalize(&lower_name);
167
168 if ascii_normalized != lower_name {
169 variations.push(escape_regex(&ascii_normalized));
170
171 let ascii_no_dots = ascii_normalized.replace('.', "");
173 if ascii_normalized != ascii_no_dots {
174 variations.push(escape_regex(&ascii_no_dots));
175 }
176 }
177
178 variations
179 })
180 .collect();
181
182 patterns.sort_by_key(|b| std::cmp::Reverse(b.len()));
184
185 Some(format!(r"(?i)({})", patterns.join("|")))
188 }
189
190 fn build_name_variants(config: &MD044Config) -> HashMap<String, usize> {
191 let mut variants = HashMap::new();
192 for (index, name) in config.names.iter().enumerate() {
193 let lower_name = name.to_lowercase();
194 variants.entry(lower_name.clone()).or_insert(index);
195
196 let lower_no_dots = lower_name.replace('.', "");
197 if lower_name != lower_no_dots {
198 variants.entry(lower_no_dots).or_insert(index);
199 }
200
201 let ascii_normalized = Self::ascii_normalize(&lower_name);
202 if ascii_normalized != lower_name {
203 variants.entry(ascii_normalized.clone()).or_insert(index);
204
205 let ascii_no_dots = ascii_normalized.replace('.', "");
206 if ascii_normalized != ascii_no_dots {
207 variants.entry(ascii_no_dots).or_insert(index);
208 }
209 }
210 }
211
212 variants
213 }
214
215 fn find_name_violations(&self, ctx: &crate::lint_context::LintContext) -> Vec<WarningPosition> {
217 let content = ctx.content;
218 let hash = fast_hash(content);
220 {
221 if let Ok(cache) = self.content_cache.lock()
223 && let Some(cached) = cache.get(&hash)
224 {
225 return cached.clone();
226 }
227 }
228
229 let mut violations = Vec::new();
230
231 let combined_regex = match &self.combined_pattern {
233 Some(pattern) => match get_cached_regex(pattern) {
234 Ok(regex) => regex,
235 Err(_) => return Vec::new(),
236 },
237 None => return Vec::new(),
238 };
239
240 let field_map = if self.ignore_fields.is_empty() {
242 Vec::new()
243 } else {
244 frontmatter_values::field_map(ctx)
245 };
246
247 for (line_idx, line_info) in ctx.lines.iter().enumerate() {
249 let line_num = line_idx + 1;
250 let line = line_info.content(ctx.content);
251
252 let trimmed = line.trim_start();
254 if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
255 continue;
256 }
257
258 if !self.config.code_blocks && line_info.in_code_block {
260 continue;
261 }
262
263 if !self.config.html_elements && line_info.in_html_block {
265 continue;
266 }
267
268 if !self.config.html_comments && line_info.in_html_comment {
270 continue;
271 }
272
273 if line_info.in_jsx_expression || line_info.in_mdx_comment {
275 continue;
276 }
277
278 if line_info.in_obsidian_comment {
280 continue;
281 }
282
283 let fm_value_offset = if line_info.in_front_matter {
286 frontmatter_values::value_offset(line)
287 } else {
288 0
289 };
290 if fm_value_offset == usize::MAX {
291 continue;
292 }
293 if line_info.in_front_matter
294 && let Some(Some(field)) = field_map.get(line_idx)
295 && self.ignore_fields.contains(field)
296 {
297 continue;
298 }
299 let fm_value_span = if line_info.in_front_matter {
300 frontmatter_values::value_span(line)
301 } else {
302 None
303 };
304
305 if is_inline_config_comment(trimmed) {
307 continue;
308 }
309
310 let line_lower = line.to_lowercase();
312 let has_line_matches = self.name_variants.keys().any(|name| line_lower.contains(name));
313
314 if !has_line_matches {
315 continue;
316 }
317
318 for cap in combined_regex.find_iter(line) {
320 let found_name = &line[cap.start()..cap.end()];
321
322 let start_pos = cap.start();
324 let end_pos = cap.end();
325
326 if start_pos < fm_value_offset {
328 continue;
329 }
330
331 let byte_pos = line_info.byte_offset + start_pos;
333 if ctx.is_in_html_tag(byte_pos) {
334 continue;
335 }
336
337 if ctx.is_in_shortcode(byte_pos) {
344 continue;
345 }
346
347 if !Self::is_at_word_boundary(line, start_pos, true) || !Self::is_at_word_boundary(line, end_pos, false)
348 {
349 continue; }
351
352 if !self.config.code_blocks {
354 if ctx.is_in_code_block_or_span(byte_pos) {
355 continue;
356 }
357 if (line_info.in_html_comment || line_info.in_html_block || line_info.in_front_matter)
361 && Self::is_in_backtick_code_in_line(line, start_pos)
362 {
363 continue;
364 }
365 }
366
367 if Self::is_in_link(ctx, byte_pos) {
369 continue;
370 }
371
372 if Self::is_in_angle_bracket_url(line, start_pos) {
376 continue;
377 }
378
379 if (line_info.in_html_comment || line_info.in_html_block || line_info.in_front_matter)
383 && Self::is_in_markdown_link_url(line, start_pos)
384 {
385 continue;
386 }
387
388 if Self::is_in_wikilink_url(ctx, byte_pos) {
393 continue;
394 }
395
396 if ctx.is_in_bare_url(byte_pos) {
402 continue;
403 }
404
405 if let Some(fm_value) = fm_value_span
412 && Self::is_in_path_like_token(line, start_pos, fm_value)
413 {
414 continue;
415 }
416
417 if let Some(&proper_name_index) = self.name_variants.get(&found_name.to_lowercase()) {
419 if found_name != self.config.names[proper_name_index] {
421 violations.push((line_num, cap.start() + 1, found_name.to_string(), proper_name_index));
422 }
423 }
424 }
425 }
426
427 if let Ok(mut cache) = self.content_cache.lock() {
429 cache.insert(hash, violations.clone());
430 }
431 violations
432 }
433
434 fn is_in_link(ctx: &crate::lint_context::LintContext, byte_pos: usize) -> bool {
442 use pulldown_cmark::LinkType;
443
444 if let Some(link) = ctx.link_containing(byte_pos) {
445 let (text_start, text_end) = if matches!(link.link_type, LinkType::WikiLink { .. }) {
447 let span = &ctx.content[link.byte_offset..link.byte_end];
454 let start = match span.find('|') {
455 Some(pipe) => link.byte_offset + pipe + 1,
456 None => link.byte_offset + 2,
457 };
458 (start, link.byte_end.saturating_sub(2))
459 } else {
460 let start = link.byte_offset + 1;
461 (start, start + link.text.len())
462 };
463
464 if byte_pos >= text_start && byte_pos < text_end {
468 let is_wikilink = matches!(link.link_type, LinkType::WikiLink { .. });
469 if Self::link_text_is_url(&link.text)
470 || (!is_wikilink && Self::link_text_matches_link_url(&link.text, &link.url))
471 {
472 return true;
473 }
474 return Self::image_verdict(ctx, byte_pos).unwrap_or(false);
480 }
481 return true;
483 }
484
485 if let Some(verdict) = Self::image_verdict(ctx, byte_pos) {
486 return verdict;
487 }
488
489 ctx.is_in_reference_def(byte_pos)
491 }
492
493 fn image_verdict(ctx: &crate::lint_context::LintContext, byte_pos: usize) -> Option<bool> {
498 let image = ctx.image_containing(byte_pos)?;
499
500 let alt_start = image.byte_offset + 2;
502 let alt_end = alt_start + image.alt_text.len();
503
504 Some(!(byte_pos >= alt_start && byte_pos < alt_end))
506 }
507
508 fn link_text_is_url(text: &str) -> bool {
510 let lower = text.trim().to_ascii_lowercase();
511 lower.starts_with("http://")
512 || lower.starts_with("https://")
513 || lower.starts_with("www.")
514 || lower.starts_with("//")
515 }
516
517 fn link_text_matches_link_url(text: &str, url: &str) -> bool {
529 let text = text.trim();
530 if !text.contains('.') {
532 return false;
533 }
534 let url_lower = url.to_ascii_lowercase();
535 let url_without_scheme = url_lower
536 .strip_prefix("https://")
537 .or_else(|| url_lower.strip_prefix("http://"))
538 .or_else(|| url_lower.strip_prefix("//"))
539 .unwrap_or(&url_lower);
540 let text_lower = text.to_ascii_lowercase();
541 if url_without_scheme == text_lower.as_str() {
543 return true;
544 }
545 url_without_scheme.len() > text_lower.len()
547 && url_without_scheme.starts_with(text_lower.as_str())
548 && matches!(
549 url_without_scheme.as_bytes().get(text_lower.len()),
550 Some(b'/') | Some(b'?') | Some(b'#')
551 )
552 }
553
554 fn is_in_angle_bracket_url(line: &str, pos: usize) -> bool {
560 let bytes = line.as_bytes();
561 let len = bytes.len();
562 let mut i = 0;
563 while i < len {
564 if bytes[i] == b'<' {
565 let after_open = i + 1;
566 if after_open < len && bytes[after_open].is_ascii_alphabetic() {
570 let mut s = after_open + 1;
571 let scheme_max = (after_open + 32).min(len);
572 while s < scheme_max
573 && (bytes[s].is_ascii_alphanumeric()
574 || bytes[s] == b'+'
575 || bytes[s] == b'-'
576 || bytes[s] == b'.')
577 {
578 s += 1;
579 }
580 if s < len && bytes[s] == b':' {
581 let mut j = s + 1;
583 let mut found_close = false;
584 while j < len {
585 match bytes[j] {
586 b'>' => {
587 found_close = true;
588 break;
589 }
590 b' ' | b'<' => break,
591 _ => j += 1,
592 }
593 }
594 if found_close && pos >= i && pos <= j {
595 return true;
596 }
597 if found_close {
598 i = j + 1;
599 continue;
600 }
601 }
602 }
603 }
604 i += 1;
605 }
606 false
607 }
608
609 fn is_in_wikilink_url(ctx: &crate::lint_context::LintContext, byte_pos: usize) -> bool {
622 use pulldown_cmark::LinkType;
623 let content = ctx.content.as_bytes();
624
625 for link in ctx.links_starting_before_or_at(byte_pos) {
626 if !matches!(link.link_type, LinkType::WikiLink { .. }) {
627 continue;
628 }
629 let wiki_end = link.byte_end;
630 if wiki_end >= byte_pos || wiki_end >= content.len() || content[wiki_end] != b'(' {
632 continue;
633 }
634 let mut depth: u32 = 1;
639 let mut k = wiki_end + 1;
640 let mut valid_destination = true;
641 while k < content.len() && depth > 0 {
642 match content[k] {
643 b'\\' => {
644 k += 1; }
646 b'(' => depth += 1,
647 b')' => depth -= 1,
648 b' ' | b'\t' | b'\n' | b'\r' => {
649 valid_destination = false;
650 break;
651 }
652 _ => {}
653 }
654 k += 1;
655 }
656 if valid_destination && depth == 0 && byte_pos > wiki_end && byte_pos < k {
659 return true;
660 }
661 }
662 false
663 }
664
665 fn is_in_markdown_link_url(line: &str, pos: usize) -> bool {
675 let bytes = line.as_bytes();
676 let len = bytes.len();
677 let mut i = 0;
678
679 while i < len {
680 if bytes[i] == b'[' && (i == 0 || bytes[i - 1] != b'\\' || (i >= 2 && bytes[i - 2] == b'\\')) {
682 let mut depth: u32 = 1;
684 let mut j = i + 1;
685 while j < len && depth > 0 {
686 match bytes[j] {
687 b'\\' => {
688 j += 1; }
690 b'[' => depth += 1,
691 b']' => depth -= 1,
692 _ => {}
693 }
694 j += 1;
695 }
696
697 if depth == 0 && j < len {
699 if bytes[j] == b'(' {
700 let url_start = j;
702 let mut paren_depth: u32 = 1;
703 let mut k = j + 1;
704 while k < len && paren_depth > 0 {
705 match bytes[k] {
706 b'\\' => {
707 k += 1; }
709 b'(' => paren_depth += 1,
710 b')' => paren_depth -= 1,
711 _ => {}
712 }
713 k += 1;
714 }
715
716 if paren_depth == 0 {
717 if pos > url_start && pos < k {
718 return true;
719 }
720 i = k;
721 continue;
722 }
723 } else if bytes[j] == b'[' {
724 let ref_start = j;
726 let mut ref_depth: u32 = 1;
727 let mut k = j + 1;
728 while k < len && ref_depth > 0 {
729 match bytes[k] {
730 b'\\' => {
731 k += 1;
732 }
733 b'[' => ref_depth += 1,
734 b']' => ref_depth -= 1,
735 _ => {}
736 }
737 k += 1;
738 }
739
740 if ref_depth == 0 {
741 if pos > ref_start && pos < k {
742 return true;
743 }
744 i = k;
745 continue;
746 }
747 }
748 }
749 }
750 i += 1;
751 }
752 false
753 }
754
755 fn is_in_backtick_code_in_line(line: &str, pos: usize) -> bool {
763 let bytes = line.as_bytes();
764 let len = bytes.len();
765 let mut i = 0;
766 while i < len {
767 if bytes[i] == b'`' {
768 let open_start = i;
770 while i < len && bytes[i] == b'`' {
771 i += 1;
772 }
773 let tick_len = i - open_start;
774
775 while i < len {
777 if bytes[i] == b'`' {
778 let close_start = i;
779 while i < len && bytes[i] == b'`' {
780 i += 1;
781 }
782 if i - close_start == tick_len {
783 let content_start = open_start + tick_len;
787 let content_end = close_start;
788 if pos >= content_start && pos < content_end {
789 return true;
790 }
791 break;
793 }
794 } else {
796 i += 1;
797 }
798 }
799 } else {
800 i += 1;
801 }
802 }
803 false
804 }
805
806 fn is_word_boundary_char(c: char) -> bool {
808 !c.is_alphanumeric()
809 }
810
811 fn is_at_word_boundary(content: &str, pos: usize, is_start: bool) -> bool {
813 if is_start {
814 if pos == 0 {
815 return true;
816 }
817 match content[..pos].chars().next_back() {
818 None => true,
819 Some(c) => Self::is_word_boundary_char(c),
820 }
821 } else {
822 if pos >= content.len() {
823 return true;
824 }
825 match content[pos..].chars().next() {
826 None => true,
827 Some(c) => Self::is_word_boundary_char(c),
828 }
829 }
830 }
831
832 fn is_in_path_like_token(line: &str, match_start: usize, fm_value: (usize, usize)) -> bool {
860 let (value_start, value_end) = fm_value;
861 if match_start < value_start || match_start >= value_end {
862 return false;
863 }
864
865 let quoted_words: Vec<&str> = if frontmatter_values::value_is_quoted(line, value_start) {
873 line[value_start..value_end].split_whitespace().collect()
874 } else {
875 Vec::new()
876 };
877 let is_single_quoted_path = !quoted_words.is_empty() && quoted_words.iter().all(|word| word.contains('/'));
878 let is_multi_word_collapse = is_single_quoted_path && quoted_words.len() > 1;
886
887 let (raw_start, raw_end) = if is_single_quoted_path {
888 (value_start, value_end)
889 } else {
890 frontmatter_values::token_bounds(line, match_start, value_start, value_end)
891 };
892
893 let (start, end) = frontmatter_values::trim_token_bounds(line, raw_start, raw_end);
894 if match_start < start || match_start >= end {
895 return false;
896 }
897
898 let token = &line[start..end];
899 if !token.contains('/') {
900 return false;
901 }
902 if token.starts_with('/') || token.starts_with("./") || token.starts_with("../") || token.starts_with("~/") {
903 return true;
904 }
905 if token.rsplit('/').next().is_some_and(|seg| seg.contains('.')) {
906 return true;
907 }
908
909 if is_multi_word_collapse {
910 return false;
911 }
912
913 let sole_value = {
917 let (ts, te) = frontmatter_values::trim_token_bounds(line, value_start, value_end);
918 ts == start && te == end
919 };
920 sole_value && token.split('/').filter(|s| !s.is_empty()).count() >= 3
921 }
922}
923
924impl Rule for MD044ProperNames {
925 fn name(&self) -> &'static str {
926 "MD044"
927 }
928
929 fn description(&self) -> &'static str {
930 "Proper names should have the correct capitalization"
931 }
932
933 fn category(&self) -> RuleCategory {
934 RuleCategory::Other
935 }
936
937 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
938 if self.config.names.is_empty() {
939 return true;
940 }
941 let content_lower = if ctx.content.is_ascii() {
943 ctx.content.to_ascii_lowercase()
944 } else {
945 ctx.content.to_lowercase()
946 };
947 !self.name_variants.keys().any(|name| content_lower.contains(name))
948 }
949
950 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
951 let content = ctx.content;
952 if content.is_empty() || self.config.names.is_empty() || self.combined_pattern.is_none() {
953 return Ok(Vec::new());
954 }
955
956 let content_lower = if content.is_ascii() {
958 content.to_ascii_lowercase()
959 } else {
960 content.to_lowercase()
961 };
962
963 let has_potential_matches = self.name_variants.keys().any(|name| content_lower.contains(name));
965
966 if !has_potential_matches {
967 return Ok(Vec::new());
968 }
969 let violations = self.find_name_violations(ctx);
970
971 let warnings = violations
972 .into_iter()
973 .map(|(line, column, found_name, proper_name_index)| {
974 let proper_name = &self.config.names[proper_name_index];
975 let line_start = ctx.line_start_byte(line).unwrap_or(0);
980 let byte_start = line_start + (column - 1);
981 let byte_end = byte_start + found_name.len();
982 let line_text = ctx.line_info(line).map_or("", |li| li.content(ctx.content));
985 let char_col = byte_to_char_count(line_text, column - 1);
986 LintWarning {
987 rule_name: Some(self.name().to_string()),
988 line,
989 column: char_col,
990 end_line: line,
991 end_column: char_col + found_name.chars().count(),
992 message: format!("Proper name '{found_name}' should be '{proper_name}'"),
993 severity: Severity::Warning,
994 fix: Some(Fix::new(byte_start..byte_end, proper_name.clone())),
995 }
996 })
997 .collect();
998
999 Ok(warnings)
1000 }
1001
1002 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
1003 if self.should_skip(ctx) {
1004 return Ok(ctx.content.to_string());
1005 }
1006 let warnings = self.check(ctx)?;
1007 if warnings.is_empty() {
1008 return Ok(ctx.content.to_string());
1009 }
1010 let warnings =
1011 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
1012 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
1013 .map_err(crate::rule::LintError::InvalidInput)
1014 }
1015
1016 fn as_any(&self) -> &dyn std::any::Any {
1017 self
1018 }
1019
1020 crate::impl_rule_config_methods!(MD044Config);
1021}
1022
1023#[cfg(test)]
1024mod tests {
1025 use super::*;
1026 use crate::lint_context::LintContext;
1027
1028 fn create_context(content: &str) -> LintContext<'_> {
1029 LintContext::new(content, crate::config::MarkdownFlavor::Standard, None)
1030 }
1031
1032 fn field_map_for(content: &str) -> Vec<Option<String>> {
1033 let ctx = create_context(content);
1034 frontmatter_values::field_map(&ctx)
1035 }
1036
1037 #[test]
1038 fn first_configured_spelling_wins_for_colliding_variants() {
1039 for (names, content, expected) in [
1040 (["Node.js", "Nodejs"], "nodejs", "Node.js"),
1041 (["Nodejs", "Node.js"], "nodejs", "Nodejs"),
1042 (["Café", "Cafe"], "cafe", "Café"),
1043 (["Cafe", "Café"], "cafe", "Cafe"),
1044 (["Node.js", "NODE.JS"], "node.js", "Node.js"),
1045 ] {
1046 let rule = MD044ProperNames::new(names.map(str::to_string).to_vec(), false);
1047 let ctx = create_context(content);
1048 let warnings = rule.check(&ctx).unwrap();
1049 assert_eq!(warnings.len(), 1);
1050 assert_eq!(warnings[0].fix.as_ref().unwrap().replacement, expected);
1051 assert_eq!(rule.fix(&ctx).unwrap(), expected);
1052 assert_eq!(rule.clone().check(&ctx).unwrap(), warnings);
1053 }
1054 }
1055
1056 #[test]
1057 fn test_field_map_nested_lines_inherit_top_level_key() {
1058 let map = field_map_for("---\nseo:\n canonical: docs/a.md\n keywords:\n - myapp\ntitle: x\n---\n");
1059 assert_eq!(map[2].as_deref(), Some("seo"));
1060 assert_eq!(map[4].as_deref(), Some("seo"));
1061 assert_eq!(map[5].as_deref(), Some("title"));
1062 }
1063
1064 #[test]
1065 fn test_field_map_block_scalar_bracket_does_not_swallow_next_key() {
1066 let map = field_map_for("---\ndescription: |\n [myapp\ntitle: myapp\n---\n");
1067 assert_eq!(map[2].as_deref(), Some("description"));
1068 assert_eq!(
1069 map[3].as_deref(),
1070 Some("title"),
1071 "an indent-0 key always starts a new key"
1072 );
1073 }
1074
1075 #[test]
1076 fn test_field_map_quoted_key_with_colon() {
1077 let map = field_map_for("---\n\"og:title\": myapp\n---\n");
1078 assert_eq!(map[1].as_deref(), Some("og:title"));
1079 }
1080
1081 #[test]
1082 fn test_field_map_top_level_sequence_clears_attribution() {
1083 let map = field_map_for("---\n- myapp\n---\n");
1084 assert_eq!(map[1], None);
1085 }
1086
1087 #[test]
1088 fn test_field_map_toml_table_body_belongs_to_table_root() {
1089 let map = field_map_for("+++\n[seo]\ncanonical = \"docs/a.md\"\n\n[[authors]]\nname = \"myapp\"\n+++\n");
1090 assert_eq!(map[2].as_deref(), Some("seo"));
1091 assert_eq!(map[5].as_deref(), Some("authors"));
1092 }
1093
1094 #[test]
1095 fn test_field_map_toml_dotted_assignment_uses_root() {
1096 let map = field_map_for("+++\nseo.canonical = \"docs/a.md\"\n+++\n");
1097 assert_eq!(map[1].as_deref(), Some("seo"));
1098 }
1099
1100 #[test]
1101 fn test_field_map_toml_array_continuation_inherits() {
1102 let map = field_map_for("+++\nseo = [\n\"docs/guide/myapp\"\n]\n+++\n");
1103 assert_eq!(map[2].as_deref(), Some("seo"));
1104 }
1105
1106 #[test]
1107 fn test_field_map_indent_zero_flow_continuation_is_not_attributed_to_parent() {
1108 let map = field_map_for("---\nseo: [\n{name: myapp}\n]\n---\n");
1111 assert_eq!(map[2].as_deref(), Some("{name"));
1112 }
1113
1114 #[test]
1115 fn test_field_map_toml_nested_array_inherits_and_title_not_corrupted() {
1116 let map = field_map_for("+++\nmatrix = [\n [1, 2],\n [3, 4],\n]\ntitle = \"x\"\n+++\n");
1117 assert_eq!(map[2].as_deref(), Some("matrix"), "nested array line inherits matrix");
1118 assert_eq!(map[3].as_deref(), Some("matrix"), "nested array line inherits matrix");
1119 assert_eq!(
1120 map[5].as_deref(),
1121 Some("title"),
1122 "title must not inherit stale attribution from a closed nested array"
1123 );
1124 }
1125
1126 #[test]
1127 fn test_field_map_toml_nested_array_last_element_without_trailing_comma() {
1128 let map = field_map_for("+++\nmatrix = [\n [1, 2],\n [2]\n]\ntitle = \"x\"\n+++\n");
1129 assert_eq!(
1130 map[3].as_deref(),
1131 Some("matrix"),
1132 "last element without a trailing comma still inherits matrix"
1133 );
1134 assert_eq!(
1135 map[5].as_deref(),
1136 Some("title"),
1137 "title must not inherit stale attribution from a closed nested array"
1138 );
1139 }
1140
1141 #[test]
1142 fn test_field_map_toml_nested_array_then_real_table_header_non_regression_guard() {
1143 let map = field_map_for("+++\nmatrix = [\n [1, 2],\n [3, 4],\n]\n\n[seo]\ncanonical = \"docs/a.md\"\n+++\n");
1150 assert_eq!(map[6].as_deref(), Some("seo"), "real table header after a closed array");
1151 assert_eq!(
1152 map[7].as_deref(),
1153 Some("seo"),
1154 "table body still attributes to the table"
1155 );
1156 }
1157
1158 #[test]
1159 fn test_field_map_toml_unclosed_array_resyncs_on_next_assignment() {
1160 let map = field_map_for("+++\nmatrix = [\n [1, 2],\ntitle = \"x\"\n+++\n");
1166 assert_eq!(
1167 map[3].as_deref(),
1168 Some("title"),
1169 "title must resync even though the array was never closed"
1170 );
1171 }
1172
1173 #[test]
1174 fn test_field_map_toml_column_zero_array_elements_inherit_and_title_not_corrupted() {
1175 let map = field_map_for("+++\nmatrix = [\n[1, 2],\n[3, 4],\n]\ntitle = \"x\"\n+++\n");
1181 assert_eq!(
1182 map[2].as_deref(),
1183 Some("matrix"),
1184 "column-0 array element inherits matrix"
1185 );
1186 assert_eq!(
1187 map[3].as_deref(),
1188 Some("matrix"),
1189 "column-0 array element inherits matrix"
1190 );
1191 assert_eq!(
1192 map[5].as_deref(),
1193 Some("title"),
1194 "title must not inherit stale attribution from a misread array element"
1195 );
1196 }
1197
1198 #[test]
1199 fn test_field_map_toml_column_zero_array_last_element_without_trailing_comma() {
1200 let map = field_map_for("+++\nmatrix = [\n[1, 2],\n[2]\n]\ntitle = \"x\"\n+++\n");
1201 assert_eq!(
1202 map[3].as_deref(),
1203 Some("matrix"),
1204 "column-0 last element without a trailing comma still inherits matrix"
1205 );
1206 assert_eq!(
1207 map[5].as_deref(),
1208 Some("title"),
1209 "title must not inherit stale attribution from a misread array element"
1210 );
1211 }
1212
1213 #[test]
1214 fn test_correctly_capitalized_names() {
1215 let rule = MD044ProperNames::new(
1216 vec![
1217 "JavaScript".to_string(),
1218 "TypeScript".to_string(),
1219 "Node.js".to_string(),
1220 ],
1221 true,
1222 );
1223
1224 let content = "This document uses JavaScript, TypeScript, and Node.js correctly.";
1225 let ctx = create_context(content);
1226 let result = rule.check(&ctx).unwrap();
1227 assert!(result.is_empty(), "Should not flag correctly capitalized names");
1228 }
1229
1230 #[test]
1231 fn test_incorrectly_capitalized_names() {
1232 let rule = MD044ProperNames::new(vec!["JavaScript".to_string(), "TypeScript".to_string()], true);
1233
1234 let content = "This document uses javascript and typescript incorrectly.";
1235 let ctx = create_context(content);
1236 let result = rule.check(&ctx).unwrap();
1237
1238 assert_eq!(result.len(), 2, "Should flag two incorrect capitalizations");
1239 assert_eq!(result[0].message, "Proper name 'javascript' should be 'JavaScript'");
1240 assert_eq!(result[0].line, 1);
1241 assert_eq!(result[0].column, 20);
1242 assert_eq!(result[1].message, "Proper name 'typescript' should be 'TypeScript'");
1243 assert_eq!(result[1].line, 1);
1244 assert_eq!(result[1].column, 35);
1245 }
1246
1247 #[test]
1248 fn test_names_at_beginning_of_sentences() {
1249 let rule = MD044ProperNames::new(vec!["JavaScript".to_string(), "Python".to_string()], true);
1250
1251 let content = "javascript is a great language. python is also popular.";
1252 let ctx = create_context(content);
1253 let result = rule.check(&ctx).unwrap();
1254
1255 assert_eq!(result.len(), 2, "Should flag names at beginning of sentences");
1256 assert_eq!(result[0].line, 1);
1257 assert_eq!(result[0].column, 1);
1258 assert_eq!(result[1].line, 1);
1259 assert_eq!(result[1].column, 33);
1260 }
1261
1262 #[test]
1263 fn test_names_in_code_blocks_checked_by_default() {
1264 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
1265
1266 let content = r#"Here is some text with JavaScript.
1267
1268```javascript
1269// This javascript should be checked
1270const lang = "javascript";
1271```
1272
1273But this javascript should be flagged."#;
1274
1275 let ctx = create_context(content);
1276 let result = rule.check(&ctx).unwrap();
1277
1278 assert_eq!(result.len(), 3, "Should flag javascript inside and outside code blocks");
1279 assert_eq!(result[0].line, 4);
1280 assert_eq!(result[1].line, 5);
1281 assert_eq!(result[2].line, 8);
1282 }
1283
1284 #[test]
1285 fn test_names_in_code_blocks_ignored_when_disabled() {
1286 let rule = MD044ProperNames::new(
1287 vec!["JavaScript".to_string()],
1288 false, );
1290
1291 let content = r#"```
1292javascript in code block
1293```"#;
1294
1295 let ctx = create_context(content);
1296 let result = rule.check(&ctx).unwrap();
1297
1298 assert_eq!(
1299 result.len(),
1300 0,
1301 "Should not flag javascript in code blocks when code_blocks is false"
1302 );
1303 }
1304
1305 #[test]
1306 fn test_names_in_inline_code_checked_by_default() {
1307 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
1308
1309 let content = "This is `javascript` in inline code and javascript outside.";
1310 let ctx = create_context(content);
1311 let result = rule.check(&ctx).unwrap();
1312
1313 assert_eq!(result.len(), 2, "Should flag javascript inside and outside inline code");
1315 assert_eq!(result[0].column, 10); assert_eq!(result[1].column, 41); }
1318
1319 #[test]
1320 fn test_multiple_names_in_same_line() {
1321 let rule = MD044ProperNames::new(
1322 vec!["JavaScript".to_string(), "TypeScript".to_string(), "React".to_string()],
1323 true,
1324 );
1325
1326 let content = "I use javascript, typescript, and react in my projects.";
1327 let ctx = create_context(content);
1328 let result = rule.check(&ctx).unwrap();
1329
1330 assert_eq!(result.len(), 3, "Should flag all three incorrect names");
1331 assert_eq!(result[0].message, "Proper name 'javascript' should be 'JavaScript'");
1332 assert_eq!(result[1].message, "Proper name 'typescript' should be 'TypeScript'");
1333 assert_eq!(result[2].message, "Proper name 'react' should be 'React'");
1334 }
1335
1336 #[test]
1337 fn test_case_sensitivity() {
1338 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
1339
1340 let content = "JAVASCRIPT, Javascript, javascript, and JavaScript variations.";
1341 let ctx = create_context(content);
1342 let result = rule.check(&ctx).unwrap();
1343
1344 assert_eq!(result.len(), 3, "Should flag all incorrect case variations");
1345 assert!(result.iter().all(|w| w.message.contains("should be 'JavaScript'")));
1347 }
1348
1349 #[test]
1350 fn test_configuration_with_custom_name_list() {
1351 let config = MD044Config {
1352 names: vec!["GitHub".to_string(), "GitLab".to_string(), "DevOps".to_string()],
1353 code_blocks: true,
1354 ..Default::default()
1355 };
1356 let rule = MD044ProperNames::from_config_struct(config);
1357
1358 let content = "We use github, gitlab, and devops for our workflow.";
1359 let ctx = create_context(content);
1360 let result = rule.check(&ctx).unwrap();
1361
1362 assert_eq!(result.len(), 3, "Should flag all custom names");
1363 assert_eq!(result[0].message, "Proper name 'github' should be 'GitHub'");
1364 assert_eq!(result[1].message, "Proper name 'gitlab' should be 'GitLab'");
1365 assert_eq!(result[2].message, "Proper name 'devops' should be 'DevOps'");
1366 }
1367
1368 #[test]
1369 fn test_empty_configuration() {
1370 let rule = MD044ProperNames::new(vec![], true);
1371
1372 let content = "This has javascript and typescript but no configured names.";
1373 let ctx = create_context(content);
1374 let result = rule.check(&ctx).unwrap();
1375
1376 assert!(result.is_empty(), "Should not flag anything with empty configuration");
1377 }
1378
1379 #[test]
1380 fn test_names_with_special_characters() {
1381 let rule = MD044ProperNames::new(
1382 vec!["Node.js".to_string(), "ASP.NET".to_string(), "C++".to_string()],
1383 true,
1384 );
1385
1386 let content = "We use nodejs, asp.net, ASP.NET, and c++ in our stack.";
1387 let ctx = create_context(content);
1388 let result = rule.check(&ctx).unwrap();
1389
1390 assert_eq!(result.len(), 3, "Should handle special characters correctly");
1395
1396 let messages: Vec<&str> = result.iter().map(|w| w.message.as_str()).collect();
1397 assert!(messages.contains(&"Proper name 'nodejs' should be 'Node.js'"));
1398 assert!(messages.contains(&"Proper name 'asp.net' should be 'ASP.NET'"));
1399 assert!(messages.contains(&"Proper name 'c++' should be 'C++'"));
1400 }
1401
1402 #[test]
1403 fn test_word_boundaries() {
1404 let rule = MD044ProperNames::new(vec!["Java".to_string(), "Script".to_string()], true);
1405
1406 let content = "JavaScript is not java or script, but Java and Script are separate.";
1407 let ctx = create_context(content);
1408 let result = rule.check(&ctx).unwrap();
1409
1410 assert_eq!(result.len(), 2, "Should respect word boundaries");
1412 assert!(result.iter().any(|w| w.column == 19)); assert!(result.iter().any(|w| w.column == 27)); }
1415
1416 #[test]
1417 fn test_fix_method() {
1418 let rule = MD044ProperNames::new(
1419 vec![
1420 "JavaScript".to_string(),
1421 "TypeScript".to_string(),
1422 "Node.js".to_string(),
1423 ],
1424 true,
1425 );
1426
1427 let content = "I love javascript, typescript, and nodejs!";
1428 let ctx = create_context(content);
1429 let fixed = rule.fix(&ctx).unwrap();
1430
1431 assert_eq!(fixed, "I love JavaScript, TypeScript, and Node.js!");
1432 }
1433
1434 #[test]
1435 fn test_fix_multiple_occurrences() {
1436 let rule = MD044ProperNames::new(vec!["Python".to_string()], true);
1437
1438 let content = "python is great. I use python daily. PYTHON is powerful.";
1439 let ctx = create_context(content);
1440 let fixed = rule.fix(&ctx).unwrap();
1441
1442 assert_eq!(fixed, "Python is great. I use Python daily. Python is powerful.");
1443 }
1444
1445 #[test]
1446 fn test_fix_checks_code_blocks_by_default() {
1447 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
1448
1449 let content = r#"I love javascript.
1450
1451```
1452const lang = "javascript";
1453```
1454
1455More javascript here."#;
1456
1457 let ctx = create_context(content);
1458 let fixed = rule.fix(&ctx).unwrap();
1459
1460 let expected = r#"I love JavaScript.
1461
1462```
1463const lang = "JavaScript";
1464```
1465
1466More JavaScript here."#;
1467
1468 assert_eq!(fixed, expected);
1469 }
1470
1471 #[test]
1472 fn test_multiline_content() {
1473 let rule = MD044ProperNames::new(vec!["Rust".to_string(), "Python".to_string()], true);
1474
1475 let content = r#"First line with rust.
1476Second line with python.
1477Third line with RUST and PYTHON."#;
1478
1479 let ctx = create_context(content);
1480 let result = rule.check(&ctx).unwrap();
1481
1482 assert_eq!(result.len(), 4, "Should flag all incorrect occurrences");
1483 assert_eq!(result[0].line, 1);
1484 assert_eq!(result[1].line, 2);
1485 assert_eq!(result[2].line, 3);
1486 assert_eq!(result[3].line, 3);
1487 }
1488
1489 #[test]
1490 fn test_default_config() {
1491 let config = MD044Config::default();
1492 assert!(config.names.is_empty());
1493 assert!(!config.code_blocks);
1494 assert!(config.html_elements);
1495 assert!(config.html_comments);
1496 }
1497
1498 #[test]
1499 fn test_default_config_checks_html_comments() {
1500 let config = MD044Config {
1501 names: vec!["JavaScript".to_string()],
1502 ..MD044Config::default()
1503 };
1504 let rule = MD044ProperNames::from_config_struct(config);
1505
1506 let content = "# Guide\n\n<!-- javascript mentioned here -->\n";
1507 let ctx = create_context(content);
1508 let result = rule.check(&ctx).unwrap();
1509
1510 assert_eq!(result.len(), 1, "Default config should check HTML comments");
1511 assert_eq!(result[0].line, 3);
1512 }
1513
1514 #[test]
1515 fn test_default_config_skips_code_blocks() {
1516 let config = MD044Config {
1517 names: vec!["JavaScript".to_string()],
1518 ..MD044Config::default()
1519 };
1520 let rule = MD044ProperNames::from_config_struct(config);
1521
1522 let content = "# Guide\n\n```\njavascript in code\n```\n";
1523 let ctx = create_context(content);
1524 let result = rule.check(&ctx).unwrap();
1525
1526 assert_eq!(result.len(), 0, "Default config should skip code blocks");
1527 }
1528
1529 #[test]
1530 fn test_standalone_html_comment_checked() {
1531 let config = MD044Config {
1532 names: vec!["Test".to_string()],
1533 ..MD044Config::default()
1534 };
1535 let rule = MD044ProperNames::from_config_struct(config);
1536
1537 let content = "# Heading\n\n<!-- this is a test example -->\n";
1538 let ctx = create_context(content);
1539 let result = rule.check(&ctx).unwrap();
1540
1541 assert_eq!(result.len(), 1, "Should flag proper name in standalone HTML comment");
1542 assert_eq!(result[0].line, 3);
1543 }
1544
1545 #[test]
1546 fn test_inline_config_comments_not_flagged() {
1547 let config = MD044Config {
1548 names: vec!["RUMDL".to_string()],
1549 ..MD044Config::default()
1550 };
1551 let rule = MD044ProperNames::from_config_struct(config);
1552
1553 let content = "<!-- rumdl-disable MD044 -->\nSome rumdl text here.\n<!-- rumdl-enable MD044 -->\n<!-- markdownlint-disable -->\nMore rumdl text.\n<!-- markdownlint-enable -->\n";
1557 let ctx = create_context(content);
1558 let result = rule.check(&ctx).unwrap();
1559
1560 assert_eq!(result.len(), 2, "Should only flag body lines, not config comments");
1561 assert_eq!(result[0].line, 2);
1562 assert_eq!(result[1].line, 5);
1563 }
1564
1565 #[test]
1566 fn test_html_comment_skipped_when_disabled() {
1567 let config = MD044Config {
1568 names: vec!["Test".to_string()],
1569 code_blocks: true,
1570 html_comments: false,
1571 ..Default::default()
1572 };
1573 let rule = MD044ProperNames::from_config_struct(config);
1574
1575 let content = "# Heading\n\n<!-- this is a test example -->\n\nRegular test here.\n";
1576 let ctx = create_context(content);
1577 let result = rule.check(&ctx).unwrap();
1578
1579 assert_eq!(
1580 result.len(),
1581 1,
1582 "Should only flag 'test' outside HTML comment when html_comments=false"
1583 );
1584 assert_eq!(result[0].line, 5);
1585 }
1586
1587 #[test]
1588 fn test_fix_corrects_html_comment_content() {
1589 let config = MD044Config {
1590 names: vec!["JavaScript".to_string()],
1591 ..MD044Config::default()
1592 };
1593 let rule = MD044ProperNames::from_config_struct(config);
1594
1595 let content = "# Guide\n\n<!-- javascript mentioned here -->\n";
1596 let ctx = create_context(content);
1597 let fixed = rule.fix(&ctx).unwrap();
1598
1599 assert_eq!(fixed, "# Guide\n\n<!-- JavaScript mentioned here -->\n");
1600 }
1601
1602 #[test]
1603 fn test_fix_does_not_modify_inline_config_comments() {
1604 let config = MD044Config {
1605 names: vec!["RUMDL".to_string()],
1606 ..MD044Config::default()
1607 };
1608 let rule = MD044ProperNames::from_config_struct(config);
1609
1610 let content = "<!-- rumdl-disable -->\nSome rumdl text.\n<!-- rumdl-enable -->\n";
1611 let ctx = create_context(content);
1612 let fixed = rule.fix(&ctx).unwrap();
1613
1614 assert!(fixed.contains("<!-- rumdl-disable -->"));
1616 assert!(fixed.contains("<!-- rumdl-enable -->"));
1617 assert!(
1619 fixed.contains("Some rumdl text."),
1620 "Line inside rumdl-disable block should not be modified by fix()"
1621 );
1622 }
1623
1624 #[test]
1625 fn test_fix_respects_inline_disable_partial() {
1626 let config = MD044Config {
1627 names: vec!["RUMDL".to_string()],
1628 ..MD044Config::default()
1629 };
1630 let rule = MD044ProperNames::from_config_struct(config);
1631
1632 let content =
1633 "<!-- rumdl-disable MD044 -->\nSome rumdl text.\n<!-- rumdl-enable MD044 -->\n\nSome rumdl text outside.\n";
1634 let ctx = create_context(content);
1635 let fixed = rule.fix(&ctx).unwrap();
1636
1637 assert!(
1639 fixed.contains("Some rumdl text.\n<!-- rumdl-enable"),
1640 "Line inside disable block should not be modified"
1641 );
1642 assert!(
1644 fixed.contains("Some RUMDL text outside."),
1645 "Line outside disable block should be fixed"
1646 );
1647 }
1648
1649 #[test]
1650 fn test_performance_with_many_names() {
1651 let mut names = vec![];
1652 for i in 0..50 {
1653 names.push(format!("ProperName{i}"));
1654 }
1655
1656 let rule = MD044ProperNames::new(names, true);
1657
1658 let content = "This has propername0, propername25, and propername49 incorrectly.";
1659 let ctx = create_context(content);
1660 let result = rule.check(&ctx).unwrap();
1661
1662 assert_eq!(result.len(), 3, "Should handle many configured names efficiently");
1663 }
1664
1665 #[test]
1666 fn test_large_name_count_performance() {
1667 let names = (0..1000).map(|i| format!("ProperName{i}")).collect::<Vec<_>>();
1670
1671 let rule = MD044ProperNames::new(names, true);
1672
1673 assert!(rule.combined_pattern.is_some());
1675
1676 let content = "This has propername0 and propername999 in it.";
1678 let ctx = create_context(content);
1679 let result = rule.check(&ctx).unwrap();
1680
1681 assert_eq!(result.len(), 2, "Should handle 1000 names without issues");
1683 }
1684
1685 #[test]
1686 fn test_cache_behavior() {
1687 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
1688
1689 let content = "Using javascript here.";
1690 let ctx = create_context(content);
1691
1692 let result1 = rule.check(&ctx).unwrap();
1694 assert_eq!(result1.len(), 1);
1695
1696 let result2 = rule.check(&ctx).unwrap();
1698 assert_eq!(result2.len(), 1);
1699
1700 assert_eq!(result1[0].line, result2[0].line);
1702 assert_eq!(result1[0].column, result2[0].column);
1703 }
1704
1705 #[test]
1706 fn test_html_comments_not_checked_when_disabled() {
1707 let config = MD044Config {
1708 names: vec!["JavaScript".to_string()],
1709 code_blocks: true, html_comments: false, ..Default::default()
1712 };
1713 let rule = MD044ProperNames::from_config_struct(config);
1714
1715 let content = r#"Regular javascript here.
1716<!-- This javascript in HTML comment should be ignored -->
1717More javascript outside."#;
1718
1719 let ctx = create_context(content);
1720 let result = rule.check(&ctx).unwrap();
1721
1722 assert_eq!(result.len(), 2, "Should only flag javascript outside HTML comments");
1723 assert_eq!(result[0].line, 1);
1724 assert_eq!(result[1].line, 3);
1725 }
1726
1727 #[test]
1728 fn test_html_comments_checked_when_enabled() {
1729 let config = MD044Config {
1730 names: vec!["JavaScript".to_string()],
1731 code_blocks: true, ..Default::default()
1733 };
1734 let rule = MD044ProperNames::from_config_struct(config);
1735
1736 let content = r#"Regular javascript here.
1737<!-- This javascript in HTML comment should be checked -->
1738More javascript outside."#;
1739
1740 let ctx = create_context(content);
1741 let result = rule.check(&ctx).unwrap();
1742
1743 assert_eq!(
1744 result.len(),
1745 3,
1746 "Should flag all javascript occurrences including in HTML comments"
1747 );
1748 }
1749
1750 #[test]
1751 fn test_indented_html_comment_escapes_via_link_and_backticks() {
1752 let config = MD044Config {
1757 names: vec!["Test".to_string()],
1758 ..Default::default()
1759 };
1760 let rule = MD044ProperNames::from_config_struct(config);
1761
1762 let content = "<!-- see the [relevant page](test.md). -->\n<!-- see `test.md` -->\n <!-- see the [relevant page](test.md). -->\n <!-- see `test.md` -->\n";
1763
1764 let ctx = create_context(content);
1765 let result = rule.check(&ctx).unwrap();
1766
1767 assert!(
1768 result.is_empty(),
1769 "'test' inside a link URL or backticks must be ignored in both column-0 and indented comments, got: {result:?}"
1770 );
1771 }
1772
1773 #[test]
1774 fn test_indented_html_comment_still_checks_bare_prose() {
1775 let config = MD044Config {
1778 names: vec!["Test".to_string()],
1779 ..Default::default()
1780 };
1781 let rule = MD044ProperNames::from_config_struct(config);
1782
1783 let content = " <!-- this is a test comment -->\n";
1784
1785 let ctx = create_context(content);
1786 let result = rule.check(&ctx).unwrap();
1787
1788 assert_eq!(
1789 result.len(),
1790 1,
1791 "bare 'test' in an indented comment is still a violation"
1792 );
1793 assert_eq!(result[0].line, 1);
1794 }
1795
1796 #[test]
1797 fn test_multiline_html_comments() {
1798 let config = MD044Config {
1799 names: vec!["Python".to_string(), "JavaScript".to_string()],
1800 code_blocks: true, html_comments: false, ..Default::default()
1803 };
1804 let rule = MD044ProperNames::from_config_struct(config);
1805
1806 let content = r#"Regular python here.
1807<!--
1808This is a multiline comment
1809with javascript and python
1810that should be ignored
1811-->
1812More javascript outside."#;
1813
1814 let ctx = create_context(content);
1815 let result = rule.check(&ctx).unwrap();
1816
1817 assert_eq!(result.len(), 2, "Should only flag names outside HTML comments");
1818 assert_eq!(result[0].line, 1); assert_eq!(result[1].line, 7); }
1821
1822 #[test]
1823 fn test_fix_preserves_html_comments_when_disabled() {
1824 let config = MD044Config {
1825 names: vec!["JavaScript".to_string()],
1826 code_blocks: true, html_comments: false, ..Default::default()
1829 };
1830 let rule = MD044ProperNames::from_config_struct(config);
1831
1832 let content = r#"javascript here.
1833<!-- javascript in comment -->
1834More javascript."#;
1835
1836 let ctx = create_context(content);
1837 let fixed = rule.fix(&ctx).unwrap();
1838
1839 let expected = r#"JavaScript here.
1840<!-- javascript in comment -->
1841More JavaScript."#;
1842
1843 assert_eq!(
1844 fixed, expected,
1845 "Should not fix names inside HTML comments when disabled"
1846 );
1847 }
1848
1849 #[test]
1850 fn test_proper_names_in_link_text_are_flagged() {
1851 let rule = MD044ProperNames::new(
1852 vec!["JavaScript".to_string(), "Node.js".to_string(), "Python".to_string()],
1853 true,
1854 );
1855
1856 let content = r#"Check this [javascript documentation](https://javascript.info) for info.
1857
1858Visit [node.js homepage](https://nodejs.org) and [python tutorial](https://python.org).
1859
1860Real javascript should be flagged.
1861
1862Also see the [typescript guide][ts-ref] for more.
1863
1864Real python should be flagged too.
1865
1866[ts-ref]: https://typescript.org/handbook"#;
1867
1868 let ctx = create_context(content);
1869 let result = rule.check(&ctx).unwrap();
1870
1871 assert_eq!(result.len(), 5, "Expected 5 warnings: 3 in link text + 2 standalone");
1878
1879 let line_1_warnings: Vec<_> = result.iter().filter(|w| w.line == 1).collect();
1881 assert_eq!(line_1_warnings.len(), 1);
1882 assert!(
1883 line_1_warnings[0]
1884 .message
1885 .contains("'javascript' should be 'JavaScript'")
1886 );
1887
1888 let line_3_warnings: Vec<_> = result.iter().filter(|w| w.line == 3).collect();
1889 assert_eq!(line_3_warnings.len(), 2); assert!(result.iter().any(|w| w.line == 5 && w.message.contains("'javascript'")));
1893 assert!(result.iter().any(|w| w.line == 9 && w.message.contains("'python'")));
1894 }
1895
1896 #[test]
1897 fn test_link_urls_not_flagged() {
1898 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
1899
1900 let content = r#"[Link Text](https://javascript.info/guide)"#;
1902
1903 let ctx = create_context(content);
1904 let result = rule.check(&ctx).unwrap();
1905
1906 assert!(result.is_empty(), "URLs should not be checked for proper names");
1908 }
1909
1910 #[test]
1911 fn test_bare_urls_not_flagged() {
1912 let rule = MD044ProperNames::new(vec!["Foo".to_string(), "JavaScript".to_string()], true);
1913
1914 let content =
1917 "https://foo.com\n\nSee https://javascript.info/foo/guide for details.\n\nMail foo@foo.com about it.\n";
1918
1919 let ctx = create_context(content);
1920 let result = rule.check(&ctx).unwrap();
1921
1922 assert!(
1923 result.is_empty(),
1924 "Bare URLs and emails should not be checked for proper names: {result:?}"
1925 );
1926 }
1927
1928 #[test]
1929 fn test_prose_around_bare_url_still_flagged() {
1930 let rule = MD044ProperNames::new(vec!["Foo".to_string()], true);
1931
1932 let content = "Use foo at https://foo.com because foo is great.\n";
1935
1936 let ctx = create_context(content);
1937 let result = rule.check(&ctx).unwrap();
1938
1939 assert_eq!(
1940 result.len(),
1941 2,
1942 "Prose occurrences around a bare URL must still be flagged: {result:?}"
1943 );
1944 assert!(result.iter().all(|w| w.message.contains("'foo' should be 'Foo'")));
1945 }
1946
1947 #[test]
1948 fn test_proper_names_in_image_alt_text_are_flagged() {
1949 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
1950
1951 let content = r#"Here is a  image.
1952
1953Real javascript should be flagged."#;
1954
1955 let ctx = create_context(content);
1956 let result = rule.check(&ctx).unwrap();
1957
1958 assert_eq!(result.len(), 2, "Expected 2 warnings: 1 in alt text + 1 standalone");
1962 assert!(result[0].message.contains("'javascript' should be 'JavaScript'"));
1963 assert!(result[0].line == 1); assert!(result[1].message.contains("'javascript' should be 'JavaScript'"));
1965 assert!(result[1].line == 3); }
1967
1968 #[test]
1969 fn test_image_urls_not_flagged() {
1970 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
1971
1972 let content = r#""#;
1974
1975 let ctx = create_context(content);
1976 let result = rule.check(&ctx).unwrap();
1977
1978 assert!(result.is_empty(), "Image URLs should not be checked for proper names");
1980 }
1981
1982 #[test]
1983 fn test_reference_link_text_flagged_but_definition_not() {
1984 let rule = MD044ProperNames::new(vec!["JavaScript".to_string(), "TypeScript".to_string()], true);
1985
1986 let content = r#"Check the [javascript guide][js-ref] for details.
1987
1988Real javascript should be flagged.
1989
1990[js-ref]: https://javascript.info/typescript/guide"#;
1991
1992 let ctx = create_context(content);
1993 let result = rule.check(&ctx).unwrap();
1994
1995 assert_eq!(result.len(), 2, "Expected 2 warnings: 1 in link text + 1 standalone");
2000 assert!(result.iter().any(|w| w.line == 1 && w.message.contains("'javascript'")));
2001 assert!(result.iter().any(|w| w.line == 3 && w.message.contains("'javascript'")));
2002 }
2003
2004 #[test]
2005 fn test_reference_definitions_not_flagged() {
2006 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
2007
2008 let content = r#"[js-ref]: https://javascript.info/guide"#;
2010
2011 let ctx = create_context(content);
2012 let result = rule.check(&ctx).unwrap();
2013
2014 assert!(result.is_empty(), "Reference definitions should not be checked");
2016 }
2017
2018 #[test]
2019 fn test_wikilinks_text_is_flagged() {
2020 let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
2021
2022 let content = r#"[[javascript]]
2024
2025Regular javascript here.
2026
2027[[JavaScript|display text]]"#;
2028
2029 let ctx = create_context(content);
2030 let result = rule.check(&ctx).unwrap();
2031
2032 assert_eq!(result.len(), 2, "Expected 2 warnings: 1 in WikiLink + 1 standalone");
2036 assert!(
2037 result
2038 .iter()
2039 .any(|w| w.line == 1 && w.column == 3 && w.message.contains("'javascript'"))
2040 );
2041 assert!(result.iter().any(|w| w.line == 3 && w.message.contains("'javascript'")));
2042 }
2043
2044 #[test]
2045 fn test_url_link_text_not_flagged() {
2046 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], true);
2047
2048 let content = r#"[https://github.com/org/repo](https://github.com/org/repo)
2050
2051[http://github.com/org/repo](http://github.com/org/repo)
2052
2053[www.github.com/org/repo](https://www.github.com/org/repo)"#;
2054
2055 let ctx = create_context(content);
2056 let result = rule.check(&ctx).unwrap();
2057
2058 assert!(
2059 result.is_empty(),
2060 "URL-like link text should not be flagged, got: {result:?}"
2061 );
2062 }
2063
2064 #[test]
2065 fn test_url_link_text_with_leading_space_not_flagged() {
2066 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], true);
2067
2068 let content = r#"[ https://github.com/org/repo](https://github.com/org/repo)"#;
2070
2071 let ctx = create_context(content);
2072 let result = rule.check(&ctx).unwrap();
2073
2074 assert!(
2075 result.is_empty(),
2076 "URL-like link text with leading space should not be flagged, got: {result:?}"
2077 );
2078 }
2079
2080 #[test]
2081 fn test_url_link_text_uppercase_scheme_not_flagged() {
2082 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], true);
2083
2084 let content = r#"[HTTPS://GITHUB.COM/org/repo](https://github.com/org/repo)"#;
2085
2086 let ctx = create_context(content);
2087 let result = rule.check(&ctx).unwrap();
2088
2089 assert!(
2090 result.is_empty(),
2091 "URL-like link text with uppercase scheme should not be flagged, got: {result:?}"
2092 );
2093 }
2094
2095 #[test]
2096 fn test_non_url_link_text_still_flagged() {
2097 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], true);
2098
2099 let content = r#"[github.com/org/repo](https://github.com/org/repo)
2103
2104[Visit github](https://github.com/org/repo)
2105
2106[//github.com/org/repo](//github.com/org/repo)
2107
2108[ftp://github.com/org/repo](ftp://github.com/org/repo)"#;
2109
2110 let ctx = create_context(content);
2111 let result = rule.check(&ctx).unwrap();
2112
2113 assert_eq!(
2118 result.len(),
2119 1,
2120 "Only prose link text should be flagged, got: {result:?}"
2121 );
2122 assert!(
2123 result.iter().any(|w| w.line == 3),
2124 "Expected 'Visit github' on line 3 to be flagged"
2125 );
2126 }
2127
2128 #[test]
2129 fn test_url_link_text_fix_not_applied() {
2130 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], true);
2131
2132 let content = "[https://github.com/org/repo](https://github.com/org/repo)\n";
2133
2134 let ctx = create_context(content);
2135 let result = rule.fix(&ctx).unwrap();
2136
2137 assert_eq!(result, content, "Fix should not modify URL-like link text");
2138 }
2139
2140 #[test]
2141 fn test_mixed_url_and_regular_link_text() {
2142 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], true);
2143
2144 let content = r#"[https://github.com/org/repo](https://github.com/org/repo)
2146
2147Visit [github documentation](https://github.com/docs) for details.
2148
2149[www.github.com/pricing](https://www.github.com/pricing)"#;
2150
2151 let ctx = create_context(content);
2152 let result = rule.check(&ctx).unwrap();
2153
2154 assert_eq!(
2156 result.len(),
2157 1,
2158 "Only non-URL link text should be flagged, got: {result:?}"
2159 );
2160 assert_eq!(result[0].line, 3);
2161 }
2162
2163 #[test]
2164 fn test_html_attribute_values_not_flagged() {
2165 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2168 let content = "# Heading\n\ntest\n\n<img src=\"www.example.test/test_image.png\">\n";
2169 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2170 let result = rule.check(&ctx).unwrap();
2171
2172 let line5_violations: Vec<_> = result.iter().filter(|w| w.line == 5).collect();
2174 assert!(
2175 line5_violations.is_empty(),
2176 "Should not flag anything inside HTML tag attributes: {line5_violations:?}"
2177 );
2178
2179 let line3_violations: Vec<_> = result.iter().filter(|w| w.line == 3).collect();
2181 assert_eq!(line3_violations.len(), 1, "Plain 'test' on line 3 should be flagged");
2182 }
2183
2184 #[test]
2185 fn test_html_text_content_still_flagged() {
2186 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2188 let content = "# Heading\n\n<a href=\"https://example.test/page\">test link</a>\n";
2189 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2190 let result = rule.check(&ctx).unwrap();
2191
2192 assert_eq!(
2195 result.len(),
2196 1,
2197 "Should flag only 'test' in anchor text, not in href: {result:?}"
2198 );
2199 assert_eq!(result[0].column, 37, "Should flag col 37 ('test link' in anchor text)");
2200 }
2201
2202 #[test]
2203 fn test_html_attribute_various_not_flagged() {
2204 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2206 let content = concat!(
2207 "# Heading\n\n",
2208 "<img src=\"test.png\" alt=\"test image\">\n",
2209 "<span class=\"test-class\" data-test=\"value\">test content</span>\n",
2210 );
2211 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2212 let result = rule.check(&ctx).unwrap();
2213
2214 assert_eq!(
2216 result.len(),
2217 1,
2218 "Should flag only 'test content' between tags: {result:?}"
2219 );
2220 assert_eq!(result[0].line, 4);
2221 }
2222
2223 #[test]
2224 fn test_plain_text_underscore_boundary_unchanged() {
2225 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2228 let content = "# Heading\n\ntest_image is here and just_test ends here\n";
2229 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2230 let result = rule.check(&ctx).unwrap();
2231
2232 assert_eq!(
2235 result.len(),
2236 2,
2237 "Should flag 'test' in both 'test_image' and 'just_test': {result:?}"
2238 );
2239 let cols: Vec<usize> = result.iter().map(|w| w.column).collect();
2240 assert!(cols.contains(&1), "Should flag col 1 (test_image): {cols:?}");
2241 assert!(cols.contains(&29), "Should flag col 29 (just_test): {cols:?}");
2242 }
2243
2244 #[test]
2245 fn test_frontmatter_yaml_keys_not_flagged() {
2246 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2249
2250 let content = "---\ntitle: Heading\ntest: Some Test value\n---\n\nTest\n";
2251 let ctx = create_context(content);
2252 let result = rule.check(&ctx).unwrap();
2253
2254 assert!(
2258 result.is_empty(),
2259 "Should not flag YAML keys or correctly capitalized values: {result:?}"
2260 );
2261 }
2262
2263 #[test]
2264 fn test_frontmatter_yaml_values_flagged() {
2265 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2267
2268 let content = "---\ntitle: Heading\nkey: a test value\n---\n\nTest\n";
2269 let ctx = create_context(content);
2270 let result = rule.check(&ctx).unwrap();
2271
2272 assert_eq!(result.len(), 1, "Should flag 'test' in YAML value: {result:?}");
2274 assert_eq!(result[0].line, 3);
2275 assert_eq!(result[0].column, 8); }
2277
2278 #[test]
2279 fn test_frontmatter_key_matches_name_not_flagged() {
2280 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2282
2283 let content = "---\ntest: other value\n---\n\nBody text\n";
2284 let ctx = create_context(content);
2285 let result = rule.check(&ctx).unwrap();
2286
2287 assert!(
2288 result.is_empty(),
2289 "Should not flag YAML key that matches configured name: {result:?}"
2290 );
2291 }
2292
2293 #[test]
2294 fn test_frontmatter_empty_value_not_flagged() {
2295 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2297
2298 let content = "---\ntest:\ntest: \n---\n\nBody text\n";
2299 let ctx = create_context(content);
2300 let result = rule.check(&ctx).unwrap();
2301
2302 assert!(
2303 result.is_empty(),
2304 "Should not flag YAML keys with empty values: {result:?}"
2305 );
2306 }
2307
2308 #[test]
2309 fn test_frontmatter_nested_yaml_key_not_flagged() {
2310 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2312
2313 let content = "---\nparent:\n test: nested value\n---\n\nBody text\n";
2314 let ctx = create_context(content);
2315 let result = rule.check(&ctx).unwrap();
2316
2317 assert!(result.is_empty(), "Should not flag nested YAML keys: {result:?}");
2319 }
2320
2321 #[test]
2322 fn test_frontmatter_list_items_checked() {
2323 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2325
2326 let content = "---\ntags:\n - test\n - other\n---\n\nBody text\n";
2327 let ctx = create_context(content);
2328 let result = rule.check(&ctx).unwrap();
2329
2330 assert_eq!(result.len(), 1, "Should flag 'test' in YAML list item: {result:?}");
2332 assert_eq!(result[0].line, 3);
2333 }
2334
2335 #[test]
2336 fn test_frontmatter_value_with_multiple_colons() {
2337 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2339
2340 let content = "---\ntest: description: a test thing\n---\n\nBody text\n";
2341 let ctx = create_context(content);
2342 let result = rule.check(&ctx).unwrap();
2343
2344 assert_eq!(
2347 result.len(),
2348 1,
2349 "Should flag 'test' in value after first colon: {result:?}"
2350 );
2351 assert_eq!(result[0].line, 2);
2352 assert!(result[0].column > 6, "Violation column should be in value portion");
2353 }
2354
2355 #[test]
2356 fn test_frontmatter_does_not_affect_body() {
2357 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2359
2360 let content = "---\ntitle: Heading\n---\n\ntest should be flagged here\n";
2361 let ctx = create_context(content);
2362 let result = rule.check(&ctx).unwrap();
2363
2364 assert_eq!(result.len(), 1, "Should flag 'test' in body text: {result:?}");
2365 assert_eq!(result[0].line, 5);
2366 }
2367
2368 #[test]
2369 fn test_frontmatter_fix_corrects_values_preserves_keys() {
2370 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2372
2373 let content = "---\ntest: a test value\n---\n\ntest here\n";
2374 let ctx = create_context(content);
2375 let fixed = rule.fix(&ctx).unwrap();
2376
2377 assert_eq!(fixed, "---\ntest: a Test value\n---\n\nTest here\n");
2379 }
2380
2381 #[test]
2382 fn test_frontmatter_multiword_value_flagged() {
2383 let rule = MD044ProperNames::new(vec!["JavaScript".to_string(), "TypeScript".to_string()], true);
2385
2386 let content = "---\ndescription: Learn javascript and typescript\n---\n\nBody\n";
2387 let ctx = create_context(content);
2388 let result = rule.check(&ctx).unwrap();
2389
2390 assert_eq!(result.len(), 2, "Should flag both names in YAML value: {result:?}");
2391 assert!(result.iter().all(|w| w.line == 2));
2392 }
2393
2394 #[test]
2395 fn test_frontmatter_yaml_comments_not_checked() {
2396 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2398
2399 let content = "---\n# test comment\ntitle: Heading\n---\n\nBody text\n";
2400 let ctx = create_context(content);
2401 let result = rule.check(&ctx).unwrap();
2402
2403 assert!(result.is_empty(), "Should not flag names in YAML comments: {result:?}");
2404 }
2405
2406 #[test]
2407 fn test_frontmatter_delimiters_not_checked() {
2408 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2410
2411 let content = "---\ntitle: Heading\n---\n\ntest here\n";
2412 let ctx = create_context(content);
2413 let result = rule.check(&ctx).unwrap();
2414
2415 assert_eq!(result.len(), 1, "Should only flag body text: {result:?}");
2417 assert_eq!(result[0].line, 5);
2418 }
2419
2420 #[test]
2421 fn test_frontmatter_continuation_lines_checked() {
2422 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2424
2425 let content = "---\ndescription: >\n a test value\n continued here\n---\n\nBody\n";
2426 let ctx = create_context(content);
2427 let result = rule.check(&ctx).unwrap();
2428
2429 assert_eq!(result.len(), 1, "Should flag 'test' in continuation line: {result:?}");
2431 assert_eq!(result[0].line, 3);
2432 }
2433
2434 #[test]
2435 fn test_frontmatter_quoted_values_checked() {
2436 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2438
2439 let content = "---\ntitle: \"a test title\"\n---\n\nBody\n";
2440 let ctx = create_context(content);
2441 let result = rule.check(&ctx).unwrap();
2442
2443 assert_eq!(result.len(), 1, "Should flag 'test' in quoted YAML value: {result:?}");
2444 assert_eq!(result[0].line, 2);
2445 }
2446
2447 #[test]
2448 fn test_frontmatter_single_quoted_values_checked() {
2449 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2451
2452 let content = "---\ntitle: 'a test title'\n---\n\nBody\n";
2453 let ctx = create_context(content);
2454 let result = rule.check(&ctx).unwrap();
2455
2456 assert_eq!(
2457 result.len(),
2458 1,
2459 "Should flag 'test' in single-quoted YAML value: {result:?}"
2460 );
2461 assert_eq!(result[0].line, 2);
2462 }
2463
2464 #[test]
2465 fn test_frontmatter_fix_multiword_values() {
2466 let rule = MD044ProperNames::new(vec!["JavaScript".to_string(), "TypeScript".to_string()], true);
2468
2469 let content = "---\ndescription: Learn javascript and typescript\n---\n\nBody\n";
2470 let ctx = create_context(content);
2471 let fixed = rule.fix(&ctx).unwrap();
2472
2473 assert_eq!(
2474 fixed,
2475 "---\ndescription: Learn JavaScript and TypeScript\n---\n\nBody\n"
2476 );
2477 }
2478
2479 #[test]
2480 fn test_frontmatter_fix_preserves_yaml_structure() {
2481 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2483
2484 let content = "---\ntags:\n - test\n - other\ntitle: a test doc\n---\n\ntest body\n";
2485 let ctx = create_context(content);
2486 let fixed = rule.fix(&ctx).unwrap();
2487
2488 assert_eq!(
2489 fixed,
2490 "---\ntags:\n - Test\n - other\ntitle: a Test doc\n---\n\nTest body\n"
2491 );
2492 }
2493
2494 #[test]
2495 fn test_frontmatter_toml_delimiters_not_checked() {
2496 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2498
2499 let content = "+++\ntitle = \"a test title\"\n+++\n\ntest body\n";
2500 let ctx = create_context(content);
2501 let result = rule.check(&ctx).unwrap();
2502
2503 assert_eq!(result.len(), 2, "Should flag TOML value and body: {result:?}");
2507 let fm_violations: Vec<_> = result.iter().filter(|w| w.line == 2).collect();
2508 assert_eq!(fm_violations.len(), 1, "Should flag 'test' in TOML value: {result:?}");
2509 let body_violations: Vec<_> = result.iter().filter(|w| w.line == 5).collect();
2510 assert_eq!(body_violations.len(), 1, "Should flag body 'test': {result:?}");
2511 }
2512
2513 #[test]
2514 fn test_frontmatter_toml_key_not_flagged() {
2515 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2517
2518 let content = "+++\ntest = \"other value\"\n+++\n\nBody text\n";
2519 let ctx = create_context(content);
2520 let result = rule.check(&ctx).unwrap();
2521
2522 assert!(
2523 result.is_empty(),
2524 "Should not flag TOML key that matches configured name: {result:?}"
2525 );
2526 }
2527
2528 #[test]
2529 fn test_frontmatter_toml_fix_preserves_keys() {
2530 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2532
2533 let content = "+++\ntest = \"a test value\"\n+++\n\ntest here\n";
2534 let ctx = create_context(content);
2535 let fixed = rule.fix(&ctx).unwrap();
2536
2537 assert_eq!(fixed, "+++\ntest = \"a Test value\"\n+++\n\nTest here\n");
2539 }
2540
2541 #[test]
2542 fn test_frontmatter_list_item_mapping_key_not_flagged() {
2543 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2546
2547 let content = "---\nitems:\n - test: nested value\n---\n\nBody text\n";
2548 let ctx = create_context(content);
2549 let result = rule.check(&ctx).unwrap();
2550
2551 assert!(
2552 result.is_empty(),
2553 "Should not flag YAML key in list-item mapping: {result:?}"
2554 );
2555 }
2556
2557 #[test]
2558 fn test_frontmatter_list_item_mapping_value_flagged() {
2559 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2561
2562 let content = "---\nitems:\n - key: a test value\n---\n\nBody text\n";
2563 let ctx = create_context(content);
2564 let result = rule.check(&ctx).unwrap();
2565
2566 assert_eq!(
2567 result.len(),
2568 1,
2569 "Should flag 'test' in list-item mapping value: {result:?}"
2570 );
2571 assert_eq!(result[0].line, 3);
2572 }
2573
2574 #[test]
2575 fn test_frontmatter_bare_list_item_still_flagged() {
2576 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2578
2579 let content = "---\ntags:\n - test\n - other\n---\n\nBody text\n";
2580 let ctx = create_context(content);
2581 let result = rule.check(&ctx).unwrap();
2582
2583 assert_eq!(result.len(), 1, "Should flag 'test' in bare list item: {result:?}");
2584 assert_eq!(result[0].line, 3);
2585 }
2586
2587 #[test]
2588 fn test_frontmatter_flow_mapping_not_flagged() {
2589 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2592
2593 let content = "---\nflow_map: {test: value, other: test}\n---\n\nBody text\n";
2594 let ctx = create_context(content);
2595 let result = rule.check(&ctx).unwrap();
2596
2597 assert!(
2598 result.is_empty(),
2599 "Should not flag names inside flow mappings: {result:?}"
2600 );
2601 }
2602
2603 #[test]
2604 fn test_frontmatter_flow_sequence_not_flagged() {
2605 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2607
2608 let content = "---\nitems: [test, other, test]\n---\n\nBody text\n";
2609 let ctx = create_context(content);
2610 let result = rule.check(&ctx).unwrap();
2611
2612 assert!(
2613 result.is_empty(),
2614 "Should not flag names inside flow sequences: {result:?}"
2615 );
2616 }
2617
2618 #[test]
2619 fn test_frontmatter_list_item_mapping_fix_preserves_key() {
2620 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2622
2623 let content = "---\nitems:\n - test: a test value\n---\n\ntest here\n";
2624 let ctx = create_context(content);
2625 let fixed = rule.fix(&ctx).unwrap();
2626
2627 assert_eq!(fixed, "---\nitems:\n - test: a Test value\n---\n\nTest here\n");
2630 }
2631
2632 #[test]
2633 fn test_frontmatter_backtick_code_not_flagged() {
2634 let config = MD044Config {
2636 names: vec!["GoodApplication".to_string()],
2637 code_blocks: false,
2638 ..MD044Config::default()
2639 };
2640 let rule = MD044ProperNames::from_config_struct(config);
2641
2642 let content = "---\ntitle: \"`goodapplication` CLI\"\n---\n\nIntroductory `goodapplication` CLI text.\n";
2643 let ctx = create_context(content);
2644 let result = rule.check(&ctx).unwrap();
2645
2646 assert!(
2648 result.is_empty(),
2649 "Should not flag names inside backticks in frontmatter or body: {result:?}"
2650 );
2651 }
2652
2653 #[test]
2654 fn test_frontmatter_unquoted_backtick_code_not_flagged() {
2655 let config = MD044Config {
2657 names: vec!["GoodApplication".to_string()],
2658 code_blocks: false,
2659 ..MD044Config::default()
2660 };
2661 let rule = MD044ProperNames::from_config_struct(config);
2662
2663 let content = "---\ntitle: `goodapplication` CLI\n---\n\nIntroductory `goodapplication` CLI text.\n";
2664 let ctx = create_context(content);
2665 let result = rule.check(&ctx).unwrap();
2666
2667 assert!(
2668 result.is_empty(),
2669 "Should not flag names inside backticks in unquoted YAML frontmatter: {result:?}"
2670 );
2671 }
2672
2673 #[test]
2674 fn test_frontmatter_bare_name_still_flagged_with_backtick_nearby() {
2675 let config = MD044Config {
2677 names: vec!["GoodApplication".to_string()],
2678 code_blocks: false,
2679 ..MD044Config::default()
2680 };
2681 let rule = MD044ProperNames::from_config_struct(config);
2682
2683 let content = "---\ntitle: goodapplication `goodapplication` CLI\n---\n\nBody\n";
2684 let ctx = create_context(content);
2685 let result = rule.check(&ctx).unwrap();
2686
2687 assert_eq!(
2689 result.len(),
2690 1,
2691 "Should flag bare name but not backtick-wrapped name: {result:?}"
2692 );
2693 assert_eq!(result[0].line, 2);
2694 assert_eq!(result[0].column, 8); }
2696
2697 #[test]
2698 fn test_frontmatter_backtick_code_with_code_blocks_true() {
2699 let config = MD044Config {
2701 names: vec!["GoodApplication".to_string()],
2702 code_blocks: true,
2703 ..MD044Config::default()
2704 };
2705 let rule = MD044ProperNames::from_config_struct(config);
2706
2707 let content = "---\ntitle: \"`goodapplication` CLI\"\n---\n\nBody\n";
2708 let ctx = create_context(content);
2709 let result = rule.check(&ctx).unwrap();
2710
2711 assert_eq!(
2713 result.len(),
2714 1,
2715 "Should flag backtick-wrapped name when code_blocks=true: {result:?}"
2716 );
2717 assert_eq!(result[0].line, 2);
2718 }
2719
2720 #[test]
2721 fn test_frontmatter_fix_preserves_backtick_code() {
2722 let config = MD044Config {
2724 names: vec!["GoodApplication".to_string()],
2725 code_blocks: false,
2726 ..MD044Config::default()
2727 };
2728 let rule = MD044ProperNames::from_config_struct(config);
2729
2730 let content = "---\ntitle: \"`goodapplication` CLI\"\n---\n\nIntroductory `goodapplication` CLI text.\n";
2731 let ctx = create_context(content);
2732 let fixed = rule.fix(&ctx).unwrap();
2733
2734 assert_eq!(
2736 fixed, content,
2737 "Fix should not modify names inside backticks in frontmatter"
2738 );
2739 }
2740
2741 fn rule_ignoring(names: &[&str], ignore: &[&str]) -> MD044ProperNames {
2742 MD044ProperNames::from_config_struct(MD044Config {
2743 names: names.iter().map(ToString::to_string).collect(),
2744 ignore_frontmatter_fields: Some(ignore.iter().map(ToString::to_string).collect()),
2745 ..Default::default()
2746 })
2747 }
2748
2749 #[test]
2750 fn test_ignore_frontmatter_field_suppresses_only_that_field() {
2751 let content = "---\ntitle: Heading for myapp\nslug: myapp-guide\n---\n";
2752 let rule = rule_ignoring(&["MyApp"], &["slug"]);
2753 let result = rule.check(&create_context(content)).unwrap();
2754 assert_eq!(result.len(), 1, "only title is flagged: {result:?}");
2755 assert_eq!(result[0].line, 2);
2756 }
2757
2758 #[test]
2759 fn test_ignore_frontmatter_field_is_case_insensitive() {
2760 let content = "---\nSlug: myapp-guide\n---\n";
2761 let rule = rule_ignoring(&["MyApp"], &["SLUG"]);
2762 assert!(rule.check(&create_context(content)).unwrap().is_empty());
2763 }
2764
2765 #[test]
2766 fn test_ignore_frontmatter_field_covers_nested_subtree() {
2767 let content = "---\nseo:\n canonical: myapp\n keywords:\n - myapp\n---\n";
2768 let rule = rule_ignoring(&["MyApp"], &["seo"]);
2769 assert!(rule.check(&create_context(content)).unwrap().is_empty());
2770 }
2771
2772 #[test]
2773 fn test_ignore_frontmatter_field_does_not_affect_body() {
2774 let content = "---\nslug: myapp\n---\n\nBody mentions myapp.\n";
2775 let rule = rule_ignoring(&["MyApp"], &["slug"]);
2776 let result = rule.check(&create_context(content)).unwrap();
2777 assert_eq!(result.len(), 1);
2778 assert_eq!(result[0].line, 5);
2779 }
2780
2781 #[test]
2782 fn test_ignore_frontmatter_field_toml_table() {
2783 let content = "+++\n[seo]\ncanonical = \"myapp\"\n+++\n";
2784 let rule = rule_ignoring(&["MyApp"], &["seo"]);
2785 assert!(rule.check(&create_context(content)).unwrap().is_empty());
2786 }
2787
2788 #[test]
2791 fn test_angle_bracket_url_in_html_comment_not_flagged() {
2792 let config = MD044Config {
2794 names: vec!["Test".to_string()],
2795 ..MD044Config::default()
2796 };
2797 let rule = MD044ProperNames::from_config_struct(config);
2798
2799 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";
2800 let ctx = create_context(content);
2801 let result = rule.check(&ctx).unwrap();
2802
2803 let line8_warnings: Vec<_> = result.iter().filter(|w| w.line == 8).collect();
2811 assert!(
2812 line8_warnings.is_empty(),
2813 "Should not flag names inside angle-bracket URLs in HTML comments: {line8_warnings:?}"
2814 );
2815 }
2816
2817 #[test]
2818 fn test_bare_url_in_html_comment_still_flagged() {
2819 let config = MD044Config {
2821 names: vec!["Test".to_string()],
2822 ..MD044Config::default()
2823 };
2824 let rule = MD044ProperNames::from_config_struct(config);
2825
2826 let content = "<!-- This is a test https://www.example.test -->\n";
2827 let ctx = create_context(content);
2828 let result = rule.check(&ctx).unwrap();
2829
2830 assert!(
2833 !result.is_empty(),
2834 "Should flag 'test' in prose text of HTML comment with bare URL"
2835 );
2836 }
2837
2838 #[test]
2839 fn test_angle_bracket_url_in_regular_markdown_not_flagged() {
2840 let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2843
2844 let content = "<https://www.example.test>\n";
2845 let ctx = create_context(content);
2846 let result = rule.check(&ctx).unwrap();
2847
2848 assert!(
2849 result.is_empty(),
2850 "Should not flag names inside angle-bracket URLs in regular markdown: {result:?}"
2851 );
2852 }
2853
2854 #[test]
2855 fn test_multiple_angle_bracket_urls_in_one_comment() {
2856 let config = MD044Config {
2857 names: vec!["Test".to_string()],
2858 ..MD044Config::default()
2859 };
2860 let rule = MD044ProperNames::from_config_struct(config);
2861
2862 let content = "<!-- See <https://test.example.com> and <https://www.example.test> for details -->\n";
2863 let ctx = create_context(content);
2864 let result = rule.check(&ctx).unwrap();
2865
2866 assert!(
2868 result.is_empty(),
2869 "Should not flag names inside multiple angle-bracket URLs: {result:?}"
2870 );
2871 }
2872
2873 #[test]
2874 fn test_angle_bracket_non_url_still_flagged() {
2875 assert!(
2878 !MD044ProperNames::is_in_angle_bracket_url("<test> which is not a URL.", 1),
2879 "is_in_angle_bracket_url should return false for non-URL angle brackets"
2880 );
2881 }
2882
2883 #[test]
2884 fn test_angle_bracket_mailto_url_not_flagged() {
2885 let config = MD044Config {
2886 names: vec!["Test".to_string()],
2887 ..MD044Config::default()
2888 };
2889 let rule = MD044ProperNames::from_config_struct(config);
2890
2891 let content = "<!-- Contact <mailto:test@example.com> for help -->\n";
2892 let ctx = create_context(content);
2893 let result = rule.check(&ctx).unwrap();
2894
2895 assert!(
2896 result.is_empty(),
2897 "Should not flag names inside angle-bracket mailto URLs: {result:?}"
2898 );
2899 }
2900
2901 #[test]
2902 fn test_angle_bracket_ftp_url_not_flagged() {
2903 let config = MD044Config {
2904 names: vec!["Test".to_string()],
2905 ..MD044Config::default()
2906 };
2907 let rule = MD044ProperNames::from_config_struct(config);
2908
2909 let content = "<!-- Download from <ftp://test.example.com/file> -->\n";
2910 let ctx = create_context(content);
2911 let result = rule.check(&ctx).unwrap();
2912
2913 assert!(
2914 result.is_empty(),
2915 "Should not flag names inside angle-bracket FTP URLs: {result:?}"
2916 );
2917 }
2918
2919 #[test]
2920 fn test_angle_bracket_url_fix_preserves_url() {
2921 let config = MD044Config {
2923 names: vec!["Test".to_string()],
2924 ..MD044Config::default()
2925 };
2926 let rule = MD044ProperNames::from_config_struct(config);
2927
2928 let content = "<!-- test text <https://www.example.test> -->\n";
2929 let ctx = create_context(content);
2930 let fixed = rule.fix(&ctx).unwrap();
2931
2932 assert!(
2934 fixed.contains("<https://www.example.test>"),
2935 "Fix should preserve angle-bracket URLs: {fixed}"
2936 );
2937 assert!(
2938 fixed.contains("Test text"),
2939 "Fix should correct prose 'test' to 'Test': {fixed}"
2940 );
2941 }
2942
2943 #[test]
2944 fn test_is_in_angle_bracket_url_helper() {
2945 let line = "text <https://example.test> more text";
2947
2948 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));
2961
2962 assert!(MD044ProperNames::is_in_angle_bracket_url(
2964 "<mailto:test@example.com>",
2965 10
2966 ));
2967
2968 assert!(MD044ProperNames::is_in_angle_bracket_url(
2970 "<ftp://test.example.com>",
2971 10
2972 ));
2973 }
2974
2975 #[test]
2976 fn test_is_in_angle_bracket_url_uppercase_scheme() {
2977 assert!(MD044ProperNames::is_in_angle_bracket_url(
2979 "<HTTPS://test.example.com>",
2980 10
2981 ));
2982 assert!(MD044ProperNames::is_in_angle_bracket_url(
2983 "<Http://test.example.com>",
2984 10
2985 ));
2986 }
2987
2988 #[test]
2989 fn test_is_in_angle_bracket_url_uncommon_schemes() {
2990 assert!(MD044ProperNames::is_in_angle_bracket_url(
2992 "<ssh://test@example.com>",
2993 10
2994 ));
2995 assert!(MD044ProperNames::is_in_angle_bracket_url("<file:///test/path>", 10));
2997 assert!(MD044ProperNames::is_in_angle_bracket_url("<data:text/plain;test>", 10));
2999 }
3000
3001 #[test]
3002 fn test_is_in_angle_bracket_url_unclosed() {
3003 assert!(!MD044ProperNames::is_in_angle_bracket_url(
3005 "<https://test.example.com",
3006 10
3007 ));
3008 }
3009
3010 #[test]
3011 fn test_vale_inline_config_comments_not_flagged() {
3012 let config = MD044Config {
3013 names: vec!["Vale".to_string(), "JavaScript".to_string()],
3014 ..MD044Config::default()
3015 };
3016 let rule = MD044ProperNames::from_config_struct(config);
3017
3018 let content = "\
3019<!-- vale off -->
3020Some javascript text here.
3021<!-- vale on -->
3022<!-- vale Style.Rule = NO -->
3023More javascript text.
3024<!-- vale Style.Rule = YES -->
3025<!-- vale JavaScript.Grammar = NO -->
3026";
3027 let ctx = create_context(content);
3028 let result = rule.check(&ctx).unwrap();
3029
3030 assert_eq!(result.len(), 2, "Should only flag body lines, not Vale config comments");
3032 assert_eq!(result[0].line, 2);
3033 assert_eq!(result[1].line, 5);
3034 }
3035
3036 #[test]
3037 fn test_remark_lint_inline_config_comments_not_flagged() {
3038 let config = MD044Config {
3039 names: vec!["JavaScript".to_string()],
3040 ..MD044Config::default()
3041 };
3042 let rule = MD044ProperNames::from_config_struct(config);
3043
3044 let content = "\
3045<!-- lint disable remark-lint-some-rule -->
3046Some javascript text here.
3047<!-- lint enable remark-lint-some-rule -->
3048<!-- lint ignore remark-lint-some-rule -->
3049More javascript text.
3050";
3051 let ctx = create_context(content);
3052 let result = rule.check(&ctx).unwrap();
3053
3054 assert_eq!(
3055 result.len(),
3056 2,
3057 "Should only flag body lines, not remark-lint config comments"
3058 );
3059 assert_eq!(result[0].line, 2);
3060 assert_eq!(result[1].line, 5);
3061 }
3062
3063 #[test]
3064 fn test_fix_does_not_modify_vale_remark_lint_comments() {
3065 let config = MD044Config {
3066 names: vec!["JavaScript".to_string(), "Vale".to_string()],
3067 ..MD044Config::default()
3068 };
3069 let rule = MD044ProperNames::from_config_struct(config);
3070
3071 let content = "\
3072<!-- vale off -->
3073Some javascript text.
3074<!-- vale on -->
3075<!-- lint disable remark-lint-some-rule -->
3076More javascript text.
3077<!-- lint enable remark-lint-some-rule -->
3078";
3079 let ctx = create_context(content);
3080 let fixed = rule.fix(&ctx).unwrap();
3081
3082 assert!(fixed.contains("<!-- vale off -->"));
3084 assert!(fixed.contains("<!-- vale on -->"));
3085 assert!(fixed.contains("<!-- lint disable remark-lint-some-rule -->"));
3086 assert!(fixed.contains("<!-- lint enable remark-lint-some-rule -->"));
3087 assert!(fixed.contains("Some JavaScript text."));
3089 assert!(fixed.contains("More JavaScript text."));
3090 }
3091
3092 #[test]
3093 fn test_mixed_tool_directives_all_skipped() {
3094 let config = MD044Config {
3095 names: vec!["JavaScript".to_string(), "Vale".to_string()],
3096 ..MD044Config::default()
3097 };
3098 let rule = MD044ProperNames::from_config_struct(config);
3099
3100 let content = "\
3101<!-- rumdl-disable MD044 -->
3102Some javascript text.
3103<!-- markdownlint-disable -->
3104More javascript text.
3105<!-- vale off -->
3106Even more javascript text.
3107<!-- lint disable some-rule -->
3108Final javascript text.
3109<!-- rumdl-enable MD044 -->
3110<!-- markdownlint-enable -->
3111<!-- vale on -->
3112<!-- lint enable some-rule -->
3113";
3114 let ctx = create_context(content);
3115 let result = rule.check(&ctx).unwrap();
3116
3117 assert_eq!(
3119 result.len(),
3120 4,
3121 "Should only flag body lines, not any tool directive comments"
3122 );
3123 assert_eq!(result[0].line, 2);
3124 assert_eq!(result[1].line, 4);
3125 assert_eq!(result[2].line, 6);
3126 assert_eq!(result[3].line, 8);
3127 }
3128
3129 #[test]
3130 fn test_vale_remark_lint_edge_cases_not_matched() {
3131 let config = MD044Config {
3132 names: vec!["JavaScript".to_string(), "Vale".to_string()],
3133 ..MD044Config::default()
3134 };
3135 let rule = MD044ProperNames::from_config_struct(config);
3136
3137 let content = "\
3145<!-- vale -->
3146<!-- vale is a tool for writing -->
3147<!-- valedictorian javascript -->
3148<!-- linting javascript tips -->
3149<!-- vale javascript -->
3150<!-- lint your javascript code -->
3151";
3152 let ctx = create_context(content);
3153 let result = rule.check(&ctx).unwrap();
3154
3155 assert_eq!(
3162 result.len(),
3163 7,
3164 "Should flag proper names in non-directive HTML comments: got {result:?}"
3165 );
3166 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); }
3174
3175 #[test]
3176 fn test_vale_style_directives_skipped() {
3177 let config = MD044Config {
3178 names: vec!["JavaScript".to_string(), "Vale".to_string()],
3179 ..MD044Config::default()
3180 };
3181 let rule = MD044ProperNames::from_config_struct(config);
3182
3183 let content = "\
3185<!-- vale style = MyStyle -->
3186<!-- vale styles = Style1, Style2 -->
3187<!-- vale MyRule.Name = YES -->
3188<!-- vale MyRule.Name = NO -->
3189Some javascript text.
3190";
3191 let ctx = create_context(content);
3192 let result = rule.check(&ctx).unwrap();
3193
3194 assert_eq!(
3196 result.len(),
3197 1,
3198 "Should only flag body lines, not Vale style/rule directives: got {result:?}"
3199 );
3200 assert_eq!(result[0].line, 5);
3201 }
3202
3203 #[test]
3206 fn test_backtick_code_single_backticks() {
3207 let line = "hello `world` bye";
3208 assert!(MD044ProperNames::is_in_backtick_code_in_line(line, 7));
3210 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 0));
3212 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 14));
3214 }
3215
3216 #[test]
3217 fn test_backtick_code_double_backticks() {
3218 let line = "a ``code`` b";
3219 assert!(MD044ProperNames::is_in_backtick_code_in_line(line, 4));
3221 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 0));
3223 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 11));
3225 }
3226
3227 #[test]
3228 fn test_backtick_code_unclosed() {
3229 let line = "a `code b";
3230 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 3));
3232 }
3233
3234 #[test]
3235 fn test_backtick_code_mismatched_count() {
3236 let line = "a `code`` b";
3238 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 3));
3241 }
3242
3243 #[test]
3244 fn test_backtick_code_multiple_spans() {
3245 let line = "`first` and `second`";
3246 assert!(MD044ProperNames::is_in_backtick_code_in_line(line, 1));
3248 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 8));
3250 assert!(MD044ProperNames::is_in_backtick_code_in_line(line, 13));
3252 }
3253
3254 #[test]
3255 fn test_backtick_code_on_backtick_boundary() {
3256 let line = "`code`";
3257 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 0));
3259 assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 5));
3261 assert!(MD044ProperNames::is_in_backtick_code_in_line(line, 1));
3263 assert!(MD044ProperNames::is_in_backtick_code_in_line(line, 4));
3264 }
3265
3266 #[test]
3272 fn test_double_bracket_link_url_not_flagged() {
3273 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3274 let content = "[[rumdl]](https://github.com/rvben/rumdl)";
3276 let ctx = create_context(content);
3277 let result = rule.check(&ctx).unwrap();
3278 assert!(
3279 result.is_empty(),
3280 "URL inside [[text]](url) must not be flagged, got: {result:?}"
3281 );
3282 }
3283
3284 #[test]
3285 fn test_double_bracket_link_url_not_fixed() {
3286 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3287 let content = "[[rumdl]](https://github.com/rvben/rumdl)\n";
3288 let ctx = create_context(content);
3289 let fixed = rule.fix(&ctx).unwrap();
3290 assert_eq!(
3291 fixed, content,
3292 "fix() must leave the URL inside [[text]](url) unchanged"
3293 );
3294 }
3295
3296 #[test]
3297 fn test_double_bracket_link_text_still_flagged() {
3298 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3299 let content = "[[github]](https://example.com)";
3301 let ctx = create_context(content);
3302 let result = rule.check(&ctx).unwrap();
3303 assert_eq!(
3304 result.len(),
3305 1,
3306 "Incorrect name in [[text]] link text should still be flagged, got: {result:?}"
3307 );
3308 assert_eq!(result[0].message, "Proper name 'github' should be 'GitHub'");
3309 }
3310
3311 #[test]
3312 fn test_double_bracket_link_mixed_line() {
3313 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3314 let content = "See [[rumdl]](https://github.com/rvben/rumdl) and github for more.";
3316 let ctx = create_context(content);
3317 let result = rule.check(&ctx).unwrap();
3318 assert_eq!(
3319 result.len(),
3320 1,
3321 "Only the standalone 'github' after the link should be flagged, got: {result:?}"
3322 );
3323 assert!(result[0].message.contains("'github'"));
3324 assert_eq!(
3326 result[0].column, 51,
3327 "Flagged column should be the trailing 'github', not the one in the URL"
3328 );
3329 }
3330
3331 #[test]
3332 fn test_regular_link_url_still_not_flagged() {
3333 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3335 let content = "[rumdl](https://github.com/rvben/rumdl)";
3336 let ctx = create_context(content);
3337 let result = rule.check(&ctx).unwrap();
3338 assert!(
3339 result.is_empty(),
3340 "URL inside regular [text](url) must still not be flagged, got: {result:?}"
3341 );
3342 }
3343
3344 #[test]
3345 fn test_link_like_text_in_code_span_still_flagged_when_code_blocks_enabled() {
3346 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], true);
3351 let content = "`[foo](https://github.com/org/repo)`";
3352 let ctx = create_context(content);
3353 let result = rule.check(&ctx).unwrap();
3354 assert_eq!(
3355 result.len(),
3356 1,
3357 "Proper name inside a code span must be flagged when code-blocks=true, got: {result:?}"
3358 );
3359 assert!(result[0].message.contains("'github'"));
3360 }
3361
3362 #[test]
3363 fn test_malformed_link_not_treated_as_url() {
3364 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3367 let content = "See [rumdl](github repo) for details.";
3368 let ctx = create_context(content);
3369 let result = rule.check(&ctx).unwrap();
3370 assert_eq!(
3371 result.len(),
3372 1,
3373 "Name inside malformed [text](url with spaces) must still be flagged, got: {result:?}"
3374 );
3375 assert!(result[0].message.contains("'github'"));
3376 }
3377
3378 #[test]
3379 fn test_wikilink_followed_by_prose_parens_still_flagged() {
3380 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3384 let content = "[[note]](github repo)";
3385 let ctx = create_context(content);
3386 let result = rule.check(&ctx).unwrap();
3387 assert_eq!(
3388 result.len(),
3389 1,
3390 "Name inside [[wikilink]](prose with spaces) must still be flagged, got: {result:?}"
3391 );
3392 assert!(result[0].message.contains("'github'"));
3393 }
3394
3395 #[test]
3397 fn test_roundtrip_fix_then_check_basic() {
3398 let rule = MD044ProperNames::new(
3399 vec![
3400 "JavaScript".to_string(),
3401 "TypeScript".to_string(),
3402 "Node.js".to_string(),
3403 ],
3404 true,
3405 );
3406 let content = "I love javascript, typescript, and nodejs!";
3407 let ctx = create_context(content);
3408 let fixed = rule.fix(&ctx).unwrap();
3409 let ctx2 = create_context(&fixed);
3410 let warnings = rule.check(&ctx2).unwrap();
3411 assert!(
3412 warnings.is_empty(),
3413 "Re-check after fix should produce zero warnings, got: {warnings:?}"
3414 );
3415 }
3416
3417 #[test]
3419 fn test_roundtrip_fix_then_check_multiline() {
3420 let rule = MD044ProperNames::new(vec!["Rust".to_string(), "Python".to_string()], true);
3421 let content = "First line with rust.\nSecond line with python.\nThird line with RUST and PYTHON.\n";
3422 let ctx = create_context(content);
3423 let fixed = rule.fix(&ctx).unwrap();
3424 let ctx2 = create_context(&fixed);
3425 let warnings = rule.check(&ctx2).unwrap();
3426 assert!(
3427 warnings.is_empty(),
3428 "Re-check after fix should produce zero warnings, got: {warnings:?}"
3429 );
3430 }
3431
3432 #[test]
3434 fn test_roundtrip_fix_then_check_inline_config() {
3435 let config = MD044Config {
3436 names: vec!["RUMDL".to_string()],
3437 ..MD044Config::default()
3438 };
3439 let rule = MD044ProperNames::from_config_struct(config);
3440 let content =
3441 "<!-- rumdl-disable MD044 -->\nSome rumdl text.\n<!-- rumdl-enable MD044 -->\n\nSome rumdl text outside.\n";
3442 let ctx = create_context(content);
3443 let fixed = rule.fix(&ctx).unwrap();
3444 assert!(
3446 fixed.contains("Some rumdl text.\n"),
3447 "Disabled block text should be preserved"
3448 );
3449 assert!(
3450 fixed.contains("Some RUMDL text outside."),
3451 "Outside text should be fixed"
3452 );
3453 }
3454
3455 #[test]
3457 fn test_roundtrip_fix_then_check_html_comments() {
3458 let config = MD044Config {
3459 names: vec!["JavaScript".to_string()],
3460 ..MD044Config::default()
3461 };
3462 let rule = MD044ProperNames::from_config_struct(config);
3463 let content = "# Guide\n\n<!-- javascript mentioned here -->\n\njavascript outside\n";
3464 let ctx = create_context(content);
3465 let fixed = rule.fix(&ctx).unwrap();
3466 let ctx2 = create_context(&fixed);
3467 let warnings = rule.check(&ctx2).unwrap();
3468 assert!(
3469 warnings.is_empty(),
3470 "Re-check after fix should produce zero warnings, got: {warnings:?}"
3471 );
3472 }
3473
3474 #[test]
3476 fn test_roundtrip_no_op_when_correct() {
3477 let rule = MD044ProperNames::new(vec!["JavaScript".to_string(), "TypeScript".to_string()], true);
3478 let content = "This uses JavaScript and TypeScript correctly.\n";
3479 let ctx = create_context(content);
3480 let fixed = rule.fix(&ctx).unwrap();
3481 assert_eq!(fixed, content, "Fix should be a no-op when content is already correct");
3482 }
3483
3484 #[test]
3487 fn test_bare_domain_link_text_not_flagged() {
3488 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3492 let content = "My site is [ravencentric.github.io](https://ravencentric.github.io).\n";
3493 let ctx = create_context(content);
3494 let result = rule.check(&ctx).unwrap();
3495 assert!(
3496 result.is_empty(),
3497 "Should not flag 'github' in a bare-domain link text that matches the link URL: {result:?}"
3498 );
3499 }
3500
3501 #[test]
3502 fn test_bare_domain_link_text_not_fixed() {
3503 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3505 let content = "My site is [ravencentric.github.io](https://ravencentric.github.io).\n";
3506 let ctx = create_context(content);
3507 let fixed = rule.fix(&ctx).unwrap();
3508 assert_eq!(
3509 fixed, content,
3510 "fix() must not alter bare-domain link text that matches the destination URL"
3511 );
3512 }
3513
3514 #[test]
3515 fn test_bare_domain_link_text_with_path_not_flagged() {
3516 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3518 let content = "Visit [ravencentric.github.io](https://ravencentric.github.io/projects).\n";
3519 let ctx = create_context(content);
3520 let result = rule.check(&ctx).unwrap();
3521 assert!(
3522 result.is_empty(),
3523 "Should not flag 'github' when bare-domain text is the hostname of its destination URL: {result:?}"
3524 );
3525 }
3526
3527 #[test]
3528 fn test_bare_domain_link_text_full_path_not_flagged() {
3529 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3531 let content = "See [ravencentric.github.io/blog](https://ravencentric.github.io/blog).\n";
3532 let ctx = create_context(content);
3533 let result = rule.check(&ctx).unwrap();
3534 assert!(
3535 result.is_empty(),
3536 "Should not flag 'github' when link text is the full URL path without scheme: {result:?}"
3537 );
3538 }
3539
3540 #[test]
3541 fn test_github_product_name_in_link_text_still_flagged() {
3542 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3545 let content = "Hosted on [github pages](https://pages.github.com).\n";
3546 let ctx = create_context(content);
3547 let result = rule.check(&ctx).unwrap();
3548 assert!(
3549 !result.is_empty(),
3550 "Should still flag 'github' in descriptive link text that does not match the destination URL"
3551 );
3552 }
3553
3554 #[test]
3555 fn test_protocol_relative_bare_domain_link_text_not_flagged() {
3556 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3558 let content = "See [github.io](//github.io).\n";
3559 let ctx = create_context(content);
3560 let result = rule.check(&ctx).unwrap();
3561 assert!(
3562 result.is_empty(),
3563 "Should not flag 'github' in bare-domain text matching a protocol-relative destination: {result:?}"
3564 );
3565 }
3566
3567 #[test]
3568 fn test_dotted_wikilink_target_still_flagged() {
3569 let rule = MD044ProperNames::new(vec!["Node.js".to_string()], false);
3574 let content = "See [[node.js]] for details.\n";
3575 let ctx = create_context(content);
3576 let result = rule.check(&ctx).unwrap();
3577 assert!(
3578 !result.is_empty(),
3579 "Should flag 'node.js' in a dotted WikiLink target: {result:?}"
3580 );
3581 }
3582
3583 #[test]
3584 fn test_bare_domain_link_text_case_insensitive_url() {
3585 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3588 let content = "See [github.io](HTTPS://github.io).\n";
3589 let ctx = create_context(content);
3590 let result = rule.check(&ctx).unwrap();
3591 assert!(
3592 result.is_empty(),
3593 "Should not flag bare-domain text when destination URL has an uppercase scheme: {result:?}"
3594 );
3595 }
3596
3597 #[test]
3598 fn test_frontmatter_value_span_strips_trailing_comment() {
3599 let line = "link: docs/guide/myapp # canonical path";
3600 let (s, e) = frontmatter_values::value_span(line).unwrap();
3601 assert_eq!(&line[s..e], "docs/guide/myapp");
3602 }
3603
3604 #[test]
3605 fn test_frontmatter_value_span_quoted_keeps_hash_and_spaces() {
3606 let line = "link: 'docs/My App/a#b'";
3607 let (s, e) = frontmatter_values::value_span(line).unwrap();
3608 assert_eq!(&line[s..e], "docs/My App/a#b");
3609 }
3610
3611 #[test]
3612 fn test_frontmatter_value_span_plain_value() {
3613 let line = "title: Heading for myapp";
3614 let (s, e) = frontmatter_values::value_span(line).unwrap();
3615 assert_eq!(&line[s..e], "Heading for myapp");
3616 }
3617
3618 #[test]
3619 fn test_frontmatter_value_span_none_for_key_only() {
3620 assert!(frontmatter_values::value_span("seo:").is_none());
3621 assert!(frontmatter_values::value_span("---").is_none());
3622 }
3623
3624 #[test]
3625 fn test_frontmatter_value_span_quoted_strips_trailing_comment() {
3626 let line = "link: 'docs/guide' # canonical path";
3627 let (s, e) = frontmatter_values::value_span(line).unwrap();
3628 assert_eq!(&line[s..e], "docs/guide");
3629 }
3630
3631 #[test]
3632 fn test_frontmatter_value_span_empty_quoted_value_is_none() {
3633 assert!(frontmatter_values::value_span("key: ''").is_none());
3634 }
3635
3636 #[test]
3637 fn test_frontmatter_value_span_unterminated_quote_strips_leading_quote() {
3638 let line = "link: 'docs/a";
3639 let (s, e) = frontmatter_values::value_span(line).unwrap();
3640 assert_eq!(&line[s..e], "docs/a");
3641 }
3642
3643 fn at(line: &str, needle: &str) -> usize {
3645 line.find(needle).expect("needle present")
3646 }
3647
3648 #[test]
3649 fn test_path_like_exempts_single_token_frontmatter_paths() {
3650 for line in [
3651 "link: this/is/a/link/to/myapp.md",
3652 "link: docs/myapp.md",
3653 "link: /abs/path/myapp.md",
3654 "link: ./myapp.md",
3655 "link: ../shared/myapp.md",
3656 ] {
3657 let span = frontmatter_values::value_span(line).unwrap();
3658 let pos = at(line, "myapp");
3659 assert!(
3660 MD044ProperNames::is_in_path_like_token(line, pos, span),
3661 "should treat as a path: {line}"
3662 );
3663 }
3664 }
3665
3666 #[test]
3667 fn test_path_like_does_not_exempt_slash_conjunction_prose() {
3668 let line = "description: We support github/gitlab/bitbucket imports.";
3671 let span = frontmatter_values::value_span(line).unwrap();
3672 assert!(
3673 !MD044ProperNames::is_in_path_like_token(line, at(line, "github"), span),
3674 "slash-separated prose is not a path"
3675 );
3676
3677 let line = "description: The javascript/typescript ecosystem is large.";
3678 let span = frontmatter_values::value_span(line).unwrap();
3679 assert!(!MD044ProperNames::is_in_path_like_token(
3680 line,
3681 at(line, "javascript"),
3682 span
3683 ));
3684 }
3685
3686 #[test]
3687 fn test_path_like_requires_a_slash_so_dotted_names_survive() {
3688 let line = "title: Use nodejs and myapp.md today.";
3689 let span = frontmatter_values::value_span(line).unwrap();
3690 assert!(!MD044ProperNames::is_in_path_like_token(line, at(line, "myapp"), span));
3691 }
3692
3693 #[test]
3694 fn test_path_like_no_slash_frontmatter_value_still_flagged() {
3695 let line = "slug: myapp-guide";
3700 let span = frontmatter_values::value_span(line).unwrap();
3701 assert!(!MD044ProperNames::is_in_path_like_token(line, at(line, "myapp"), span));
3702 }
3703
3704 #[test]
3705 fn test_path_like_returns_false_outside_value_span() {
3706 let line = "myapp: docs/guide/myapp";
3709 let span = frontmatter_values::value_span(line).unwrap();
3710 let key_pos = 0;
3711 assert!(!MD044ProperNames::is_in_path_like_token(line, key_pos, span));
3712 }
3713
3714 #[test]
3715 fn test_path_like_three_segments_only_as_sole_frontmatter_value() {
3716 let line = "link: docs/guide/myapp";
3717 let span = frontmatter_values::value_span(line).unwrap();
3718 assert!(MD044ProperNames::is_in_path_like_token(line, at(line, "myapp"), span));
3719
3720 let line = "description: We support github/gitlab/bitbucket now";
3721 let span = frontmatter_values::value_span(line).unwrap();
3722 assert!(
3723 !MD044ProperNames::is_in_path_like_token(line, at(line, "github"), span),
3724 "multi-token value gets body treatment"
3725 );
3726 }
3727
3728 #[test]
3729 fn test_path_like_quoted_value_with_spaces() {
3730 let line = "link: 'docs/My App/myapp.md'";
3733 let span = frontmatter_values::value_span(line).unwrap();
3734 assert!(MD044ProperNames::is_in_path_like_token(line, at(line, "myapp"), span));
3735 }
3736
3737 #[test]
3738 fn test_path_like_quoted_value_with_spaces_no_extension_not_exempt() {
3739 let line = "link: 'docs/My App/myapp'";
3746 let span = frontmatter_values::value_span(line).unwrap();
3747 assert!(!MD044ProperNames::is_in_path_like_token(line, at(line, "myapp"), span));
3748 }
3749
3750 #[test]
3751 fn test_path_like_trailing_comment_is_still_sole_value() {
3752 let line = "link: docs/guide/myapp # canonical path";
3753 let span = frontmatter_values::value_span(line).unwrap();
3754 assert!(MD044ProperNames::is_in_path_like_token(line, at(line, "myapp"), span));
3755 }
3756
3757 #[test]
3758 fn test_path_like_trailing_punctuation_trimmed() {
3759 let line = "link: docs/myapp.md, then leave.";
3760 let span = frontmatter_values::value_span(line).unwrap();
3761 assert!(MD044ProperNames::is_in_path_like_token(line, at(line, "myapp"), span));
3762 }
3763
3764 #[test]
3765 fn test_trim_token_bounds_reaches_fixpoint_after_punctuation_exposes_wrapper() {
3766 let line = r#"See "docs/myapp.md", then leave."#;
3767 let raw_start = at(line, "\"docs");
3768 let raw_end = raw_start + r#""docs/myapp.md","#.len();
3769 assert_eq!(&line[raw_start..raw_end], r#""docs/myapp.md","#);
3770 let (start, end) = frontmatter_values::trim_token_bounds(line, raw_start, raw_end);
3771 assert_eq!(&line[start..end], "docs/myapp.md");
3772 }
3773
3774 #[test]
3775 fn test_trim_token_bounds_reaches_fixpoint_with_multiple_trailing_wrappers() {
3776 let line = r#"("docs/myapp.md")."#;
3777 let (start, end) = frontmatter_values::trim_token_bounds(line, 0, line.len());
3778 assert_eq!(&line[start..end], "docs/myapp.md");
3779 }
3780
3781 #[test]
3782 fn test_frontmatter_link_path_not_flagged() {
3783 let content = "---\ntitle: Heading for MyApp\nlink: 'this/is/a/link/to/myapp.md'\n---\n\nBody.\n";
3784 let rule = MD044ProperNames::new(vec!["MyApp".to_string()], false);
3785 let ctx = create_context(content);
3786 let result = rule.check(&ctx).unwrap();
3787 assert!(
3788 result.is_empty(),
3789 "path in a frontmatter value must not be flagged: {result:?}"
3790 );
3791 }
3792
3793 #[test]
3794 fn test_fix_does_not_corrupt_frontmatter_link_path() {
3795 let content = "---\nlink: 'this/is/a/link/to/myapp.md'\n---\n\nBody.\n";
3796 let rule = MD044ProperNames::new(vec!["MyApp".to_string()], false);
3797 let ctx = create_context(content);
3798 assert_eq!(rule.fix(&ctx).unwrap(), content, "fix must not rewrite a path");
3799 }
3800
3801 #[test]
3812 fn test_body_prose_parenthesized_disambiguator_is_case_corrected() {
3813 let content = "See docs/myapp(1).md here.\n";
3814 let rule = MD044ProperNames::new(vec!["MyApp".to_string()], false);
3815 let ctx = create_context(content);
3816 let result = rule.check(&ctx).unwrap();
3817 assert_eq!(result.len(), 1, "body occurrence is flagged: {result:?}");
3818 assert_eq!(rule.fix(&ctx).unwrap(), "See docs/MyApp(1).md here.\n");
3819 }
3820
3821 #[test]
3822 fn test_body_prose_bracketed_dynamic_segment_is_case_corrected() {
3823 let content = "See docs/[myapp].md here.\n";
3824 let rule = MD044ProperNames::new(vec!["MyApp".to_string()], false);
3825 let ctx = create_context(content);
3826 let result = rule.check(&ctx).unwrap();
3827 assert_eq!(result.len(), 1, "body occurrence is flagged: {result:?}");
3828 assert_eq!(rule.fix(&ctx).unwrap(), "See docs/[MyApp].md here.\n");
3829 }
3830
3831 #[test]
3832 fn test_body_prose_nextjs_catch_all_segment_is_case_corrected() {
3833 let content = "pages/[[...myapp]].tsx are catch-all routes.\n";
3836 let rule = MD044ProperNames::new(vec!["MyApp".to_string()], false);
3837 let ctx = create_context(content);
3838 let result = rule.check(&ctx).unwrap();
3839 assert_eq!(result.len(), 1, "body occurrence is flagged: {result:?}");
3840 assert_eq!(
3841 rule.fix(&ctx).unwrap(),
3842 "pages/[[...MyApp]].tsx are catch-all routes.\n"
3843 );
3844 }
3845
3846 #[test]
3847 fn test_two_adjacent_whitespace_free_links_both_flagged() {
3848 let content = "[myapp](https://a.com)[github](https://b.com)\n";
3852 let rule = MD044ProperNames::new(vec!["MyApp".to_string(), "GitHub".to_string()], false);
3853 let ctx = create_context(content);
3854 let result = rule.check(&ctx).unwrap();
3855 assert_eq!(result.len(), 2, "both link texts must be flagged: {result:?}");
3856 assert!(result.iter().any(|w| w.message.contains("'myapp'")));
3857 assert!(result.iter().any(|w| w.message.contains("'github'")));
3858 }
3859
3860 #[test]
3861 fn test_fix_does_not_corrupt_frontmatter_path_with_route_group_named_after_proper_name() {
3862 let content = "---\nlink: src/(myapp)/page.tsx\n---\n\nBody.\n";
3865 let rule = MD044ProperNames::new(vec!["MyApp".to_string()], false);
3866 let ctx = create_context(content);
3867 assert_eq!(
3868 rule.fix(&ctx).unwrap(),
3869 content,
3870 "fix must not rewrite a frontmatter path whose route-group directory name is the proper name"
3871 );
3872 }
3873
3874 #[test]
3875 fn test_quoted_frontmatter_value_slash_conjunction_prose_still_flagged() {
3876 let content = "---\ndescription: \"We support github/gitlab/bitbucket now\"\n---\n\nBody.\n";
3881 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3882 let ctx = create_context(content);
3883 let result = rule.check(&ctx).unwrap();
3884 assert_eq!(
3885 result.len(),
3886 1,
3887 "quoted prose value must still flag 'github': {result:?}"
3888 );
3889 }
3890
3891 #[test]
3892 fn test_quoted_frontmatter_value_single_slash_word_with_unrelated_dot_still_flagged() {
3893 let content = "---\ndescription: \"We use myapp/gitlab and version 1.0 e.g. weekly\"\n---\n\nBody.\n";
3896 let rule = MD044ProperNames::new(vec!["MyApp".to_string()], false);
3897 let ctx = create_context(content);
3898 let result = rule.check(&ctx).unwrap();
3899 assert_eq!(
3900 result.len(),
3901 1,
3902 "quoted prose value must still flag 'myapp': {result:?}"
3903 );
3904 }
3905
3906 #[test]
3907 fn test_quoted_toml_frontmatter_value_slash_conjunction_prose_still_flagged() {
3908 let content = "+++\ndescription = \"We support github/gitlab/bitbucket now\"\n+++\n\nBody.\n";
3911 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3912 let ctx = create_context(content);
3913 let result = rule.check(&ctx).unwrap();
3914 assert_eq!(
3915 result.len(),
3916 1,
3917 "TOML quoted prose value must still flag 'github': {result:?}"
3918 );
3919 }
3920
3921 #[test]
3922 fn test_path_like_collapsed_multiword_no_extension_not_exempt() {
3923 for line in [
3930 r#"description: "myapp/gitlab github/bitbucket""#,
3931 r#"description: "and/or this/that myapp/gitlab""#,
3932 r#"description: "he/him she/her myapp/gitlab""#,
3933 ] {
3934 let span = frontmatter_values::value_span(line).unwrap();
3935 for needle in ["myapp", "gitlab"] {
3936 if let Some(byte_pos) = line.find(needle) {
3937 assert!(
3938 !MD044ProperNames::is_in_path_like_token(line, byte_pos, span),
3939 "collapsed multi-word value must not exempt '{needle}': {line}"
3940 );
3941 }
3942 }
3943 }
3944 }
3945
3946 #[test]
3947 fn test_path_like_collapsed_multiword_no_extension_not_exempt_toml() {
3948 let line = r#"description = "myapp/gitlab github/bitbucket""#;
3949 let span = frontmatter_values::value_span(line).unwrap();
3950 for needle in ["myapp", "gitlab", "github", "bitbucket"] {
3951 let byte_pos = at(line, needle);
3952 assert!(
3953 !MD044ProperNames::is_in_path_like_token(line, byte_pos, span),
3954 "collapsed multi-word TOML value must not exempt '{needle}'"
3955 );
3956 }
3957 }
3958
3959 #[test]
3960 fn test_frontmatter_collapsed_multiword_names_all_flagged_yaml() {
3961 let content = "---\ndescription: \"myapp/gitlab github/bitbucket\"\n---\n\nBody.\n";
3962 let rule = MD044ProperNames::new(
3963 vec![
3964 "MyApp".to_string(),
3965 "GitLab".to_string(),
3966 "GitHub".to_string(),
3967 "Bitbucket".to_string(),
3968 ],
3969 false,
3970 );
3971 let ctx = create_context(content);
3972 let result = rule.check(&ctx).unwrap();
3973 assert_eq!(
3974 result.len(),
3975 4,
3976 "all four names in the collapsed multi-word value must be flagged: {result:?}"
3977 );
3978 }
3979
3980 #[test]
3981 fn test_frontmatter_collapsed_multiword_names_all_flagged_toml() {
3982 let content = "+++\ndescription = \"myapp/gitlab github/bitbucket\"\n+++\n\nBody.\n";
3983 let rule = MD044ProperNames::new(
3984 vec![
3985 "MyApp".to_string(),
3986 "GitLab".to_string(),
3987 "GitHub".to_string(),
3988 "Bitbucket".to_string(),
3989 ],
3990 false,
3991 );
3992 let ctx = create_context(content);
3993 let result = rule.check(&ctx).unwrap();
3994 assert_eq!(
3995 result.len(),
3996 4,
3997 "all four names in the collapsed multi-word TOML value must be flagged: {result:?}"
3998 );
3999 }
4000
4001 #[test]
4002 fn test_frontmatter_collapsed_multiword_conjunction_pairs_flagged() {
4003 let content = "---\ndescription: \"and/or this/that myapp/gitlab\"\n---\n\nBody.\n";
4004 let rule = MD044ProperNames::new(vec!["MyApp".to_string(), "GitLab".to_string()], false);
4005 let ctx = create_context(content);
4006 let result = rule.check(&ctx).unwrap();
4007 assert_eq!(
4008 result.len(),
4009 2,
4010 "myapp and gitlab must both be flagged despite the surrounding slash pairs: {result:?}"
4011 );
4012 }
4013
4014 #[test]
4015 fn test_frontmatter_collapsed_multiword_pronoun_pairs_flagged() {
4016 let content = "---\ndescription: \"he/him she/her myapp/gitlab\"\n---\n\nBody.\n";
4017 let rule = MD044ProperNames::new(vec!["MyApp".to_string(), "GitLab".to_string()], false);
4018 let ctx = create_context(content);
4019 let result = rule.check(&ctx).unwrap();
4020 assert_eq!(
4021 result.len(),
4022 2,
4023 "myapp and gitlab must both be flagged despite the surrounding slash pairs: {result:?}"
4024 );
4025 }
4026
4027 #[test]
4033 fn test_body_prose_path_is_flagged_frontmatter_only_scope() {
4034 let content = "See docs/myapp.md for details about myapp.\n";
4035 let rule = MD044ProperNames::new(vec!["MyApp".to_string()], false);
4036 let ctx = create_context(content);
4037 let result = rule.check(&ctx).unwrap();
4038 assert_eq!(
4039 result.len(),
4040 2,
4041 "both the path occurrence and the prose occurrence are flagged in body text: {result:?}"
4042 );
4043 assert_eq!(rule.fix(&ctx).unwrap(), "See docs/MyApp.md for details about MyApp.\n");
4044 }
4045
4046 #[test]
4047 fn test_slash_conjunction_prose_still_flagged() {
4048 let content = "We support github/gitlab imports.\n";
4049 let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
4050 let ctx = create_context(content);
4051 assert_eq!(rule.check(&ctx).unwrap().len(), 1);
4052 }
4053}