use crate::utils::fast_hash;
use crate::utils::regex_cache::{escape_regex, get_cached_regex};
use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
use crate::utils::frontmatter_values;
use crate::utils::range_utils::byte_to_char_count;
use std::collections::{HashMap, HashSet};
use std::sync::{Arc, Mutex};
mod md044_config;
pub(super) use md044_config::MD044Config;
type WarningPosition = (usize, usize, String);
fn is_inline_config_comment(trimmed: &str) -> bool {
trimmed.starts_with("<!-- rumdl-")
|| trimmed.starts_with("<!-- markdownlint-")
|| trimmed.starts_with("<!-- vale off")
|| trimmed.starts_with("<!-- vale on")
|| (trimmed.starts_with("<!-- vale ") && trimmed.contains(" = "))
|| trimmed.starts_with("<!-- vale style")
|| trimmed.starts_with("<!-- lint disable ")
|| trimmed.starts_with("<!-- lint enable ")
|| trimmed.starts_with("<!-- lint ignore ")
}
#[derive(Clone)]
pub struct MD044ProperNames {
config: MD044Config,
combined_pattern: Option<String>,
name_variants: Vec<String>,
ignore_fields: HashSet<String>,
content_cache: Arc<Mutex<HashMap<u64, Vec<WarningPosition>>>>,
}
impl MD044ProperNames {
pub fn new(names: Vec<String>, code_blocks: bool) -> Self {
let config = MD044Config {
names,
code_blocks,
..Default::default()
};
let combined_pattern = Self::create_combined_pattern(&config);
let name_variants = Self::build_name_variants(&config);
let ignore_fields = config
.ignore_frontmatter_fields
.iter()
.flatten()
.map(|f| f.to_lowercase())
.collect();
Self {
config,
combined_pattern,
name_variants,
ignore_fields,
content_cache: Arc::new(Mutex::new(HashMap::new())),
}
}
fn ascii_normalize(s: &str) -> String {
s.replace(['é', 'è', 'ê', 'ë'], "e")
.replace(['à ', 'á', 'â', 'ä', 'ã', 'å'], "a")
.replace(['ï', 'î', 'Ã', 'ì'], "i")
.replace(['ü', 'ú', 'ù', 'û'], "u")
.replace(['ö', 'ó', 'ò', 'ô', 'õ'], "o")
.replace('ñ', "n")
.replace('ç', "c")
}
pub fn from_config_struct(config: MD044Config) -> Self {
let combined_pattern = Self::create_combined_pattern(&config);
let name_variants = Self::build_name_variants(&config);
let ignore_fields = config
.ignore_frontmatter_fields
.iter()
.flatten()
.map(|f| f.to_lowercase())
.collect();
Self {
config,
combined_pattern,
name_variants,
ignore_fields,
content_cache: Arc::new(Mutex::new(HashMap::new())),
}
}
fn create_combined_pattern(config: &MD044Config) -> Option<String> {
if config.names.is_empty() {
return None;
}
let mut patterns: Vec<String> = config
.names
.iter()
.flat_map(|name| {
let mut variations = vec![];
let lower_name = name.to_lowercase();
variations.push(escape_regex(&lower_name));
let lower_name_no_dots = lower_name.replace('.', "");
if lower_name != lower_name_no_dots {
variations.push(escape_regex(&lower_name_no_dots));
}
let ascii_normalized = Self::ascii_normalize(&lower_name);
if ascii_normalized != lower_name {
variations.push(escape_regex(&ascii_normalized));
let ascii_no_dots = ascii_normalized.replace('.', "");
if ascii_normalized != ascii_no_dots {
variations.push(escape_regex(&ascii_no_dots));
}
}
variations
})
.collect();
patterns.sort_by_key(|b| std::cmp::Reverse(b.len()));
Some(format!(r"(?i)({})", patterns.join("|")))
}
fn build_name_variants(config: &MD044Config) -> Vec<String> {
let mut variants = HashSet::new();
for name in &config.names {
let lower_name = name.to_lowercase();
variants.insert(lower_name.clone());
let lower_no_dots = lower_name.replace('.', "");
if lower_name != lower_no_dots {
variants.insert(lower_no_dots);
}
let ascii_normalized = Self::ascii_normalize(&lower_name);
if ascii_normalized != lower_name {
variants.insert(ascii_normalized.clone());
let ascii_no_dots = ascii_normalized.replace('.', "");
if ascii_normalized != ascii_no_dots {
variants.insert(ascii_no_dots);
}
}
}
variants.into_iter().collect()
}
fn find_name_violations(
&self,
content: &str,
ctx: &crate::lint_context::LintContext,
content_lower: &str,
) -> Vec<WarningPosition> {
if self.config.names.is_empty() || content.is_empty() || self.combined_pattern.is_none() {
return Vec::new();
}
let has_potential_matches = self.name_variants.iter().any(|name| content_lower.contains(name));
if !has_potential_matches {
return Vec::new();
}
let hash = fast_hash(content);
{
if let Ok(cache) = self.content_cache.lock()
&& let Some(cached) = cache.get(&hash)
{
return cached.clone();
}
}
let mut violations = Vec::new();
let combined_regex = match &self.combined_pattern {
Some(pattern) => match get_cached_regex(pattern) {
Ok(regex) => regex,
Err(_) => return Vec::new(),
},
None => return Vec::new(),
};
let field_map = if self.ignore_fields.is_empty() {
Vec::new()
} else {
frontmatter_values::field_map(ctx)
};
for (line_idx, line_info) in ctx.lines.iter().enumerate() {
let line_num = line_idx + 1;
let line = line_info.content(ctx.content);
let trimmed = line.trim_start();
if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
continue;
}
if !self.config.code_blocks && line_info.in_code_block {
continue;
}
if !self.config.html_elements && line_info.in_html_block {
continue;
}
if !self.config.html_comments && line_info.in_html_comment {
continue;
}
if line_info.in_jsx_expression || line_info.in_mdx_comment {
continue;
}
if line_info.in_obsidian_comment {
continue;
}
let fm_value_offset = if line_info.in_front_matter {
frontmatter_values::value_offset(line)
} else {
0
};
if fm_value_offset == usize::MAX {
continue;
}
if line_info.in_front_matter
&& let Some(Some(field)) = field_map.get(line_idx)
&& self.ignore_fields.contains(field)
{
continue;
}
let fm_value_span = if line_info.in_front_matter {
frontmatter_values::value_span(line)
} else {
None
};
if is_inline_config_comment(trimmed) {
continue;
}
let line_lower = line.to_lowercase();
let has_line_matches = self.name_variants.iter().any(|name| line_lower.contains(name));
if !has_line_matches {
continue;
}
for cap in combined_regex.find_iter(line) {
let found_name = &line[cap.start()..cap.end()];
let start_pos = cap.start();
let end_pos = cap.end();
if start_pos < fm_value_offset {
continue;
}
let byte_pos = line_info.byte_offset + start_pos;
if ctx.is_in_html_tag(byte_pos) {
continue;
}
if ctx.is_in_shortcode(byte_pos) {
continue;
}
if !Self::is_at_word_boundary(line, start_pos, true) || !Self::is_at_word_boundary(line, end_pos, false)
{
continue; }
if !self.config.code_blocks {
if ctx.is_in_code_block_or_span(byte_pos) {
continue;
}
if (line_info.in_html_comment || line_info.in_html_block || line_info.in_front_matter)
&& Self::is_in_backtick_code_in_line(line, start_pos)
{
continue;
}
}
if Self::is_in_link(ctx, byte_pos) {
continue;
}
if Self::is_in_angle_bracket_url(line, start_pos) {
continue;
}
if (line_info.in_html_comment || line_info.in_html_block || line_info.in_front_matter)
&& Self::is_in_markdown_link_url(line, start_pos)
{
continue;
}
if Self::is_in_wikilink_url(ctx, byte_pos) {
continue;
}
if ctx.is_in_bare_url(byte_pos) {
continue;
}
if let Some(fm_value) = fm_value_span
&& Self::is_in_path_like_token(line, start_pos, fm_value)
{
continue;
}
if let Some(proper_name) = self.get_proper_name_for(found_name) {
if found_name != proper_name {
violations.push((line_num, cap.start() + 1, found_name.to_string()));
}
}
}
}
if let Ok(mut cache) = self.content_cache.lock() {
cache.insert(hash, violations.clone());
}
violations
}
fn is_in_link(ctx: &crate::lint_context::LintContext, byte_pos: usize) -> bool {
use pulldown_cmark::LinkType;
if let Some(link) = ctx.link_containing(byte_pos) {
let (text_start, text_end) = if matches!(link.link_type, LinkType::WikiLink { .. }) {
let span = &ctx.content[link.byte_offset..link.byte_end];
let start = match span.find('|') {
Some(pipe) => link.byte_offset + pipe + 1,
None => link.byte_offset + 2,
};
(start, link.byte_end.saturating_sub(2))
} else {
let start = link.byte_offset + 1;
(start, start + link.text.len())
};
if byte_pos >= text_start && byte_pos < text_end {
let is_wikilink = matches!(link.link_type, LinkType::WikiLink { .. });
if Self::link_text_is_url(&link.text)
|| (!is_wikilink && Self::link_text_matches_link_url(&link.text, &link.url))
{
return true;
}
return Self::image_verdict(ctx, byte_pos).unwrap_or(false);
}
return true;
}
if let Some(verdict) = Self::image_verdict(ctx, byte_pos) {
return verdict;
}
ctx.is_in_reference_def(byte_pos)
}
fn image_verdict(ctx: &crate::lint_context::LintContext, byte_pos: usize) -> Option<bool> {
let image = ctx.image_containing(byte_pos)?;
let alt_start = image.byte_offset + 2;
let alt_end = alt_start + image.alt_text.len();
Some(!(byte_pos >= alt_start && byte_pos < alt_end))
}
fn link_text_is_url(text: &str) -> bool {
let lower = text.trim().to_ascii_lowercase();
lower.starts_with("http://")
|| lower.starts_with("https://")
|| lower.starts_with("www.")
|| lower.starts_with("//")
}
fn link_text_matches_link_url(text: &str, url: &str) -> bool {
let text = text.trim();
if !text.contains('.') {
return false;
}
let url_lower = url.to_ascii_lowercase();
let url_without_scheme = url_lower
.strip_prefix("https://")
.or_else(|| url_lower.strip_prefix("http://"))
.or_else(|| url_lower.strip_prefix("//"))
.unwrap_or(&url_lower);
let text_lower = text.to_ascii_lowercase();
if url_without_scheme == text_lower.as_str() {
return true;
}
url_without_scheme.len() > text_lower.len()
&& url_without_scheme.starts_with(text_lower.as_str())
&& matches!(
url_without_scheme.as_bytes().get(text_lower.len()),
Some(b'/') | Some(b'?') | Some(b'#')
)
}
fn is_in_angle_bracket_url(line: &str, pos: usize) -> bool {
let bytes = line.as_bytes();
let len = bytes.len();
let mut i = 0;
while i < len {
if bytes[i] == b'<' {
let after_open = i + 1;
if after_open < len && bytes[after_open].is_ascii_alphabetic() {
let mut s = after_open + 1;
let scheme_max = (after_open + 32).min(len);
while s < scheme_max
&& (bytes[s].is_ascii_alphanumeric()
|| bytes[s] == b'+'
|| bytes[s] == b'-'
|| bytes[s] == b'.')
{
s += 1;
}
if s < len && bytes[s] == b':' {
let mut j = s + 1;
let mut found_close = false;
while j < len {
match bytes[j] {
b'>' => {
found_close = true;
break;
}
b' ' | b'<' => break,
_ => j += 1,
}
}
if found_close && pos >= i && pos <= j {
return true;
}
if found_close {
i = j + 1;
continue;
}
}
}
}
i += 1;
}
false
}
fn is_in_wikilink_url(ctx: &crate::lint_context::LintContext, byte_pos: usize) -> bool {
use pulldown_cmark::LinkType;
let content = ctx.content.as_bytes();
for link in ctx.links_starting_before_or_at(byte_pos) {
if !matches!(link.link_type, LinkType::WikiLink { .. }) {
continue;
}
let wiki_end = link.byte_end;
if wiki_end >= byte_pos || wiki_end >= content.len() || content[wiki_end] != b'(' {
continue;
}
let mut depth: u32 = 1;
let mut k = wiki_end + 1;
let mut valid_destination = true;
while k < content.len() && depth > 0 {
match content[k] {
b'\\' => {
k += 1; }
b'(' => depth += 1,
b')' => depth -= 1,
b' ' | b'\t' | b'\n' | b'\r' => {
valid_destination = false;
break;
}
_ => {}
}
k += 1;
}
if valid_destination && depth == 0 && byte_pos > wiki_end && byte_pos < k {
return true;
}
}
false
}
fn is_in_markdown_link_url(line: &str, pos: usize) -> bool {
let bytes = line.as_bytes();
let len = bytes.len();
let mut i = 0;
while i < len {
if bytes[i] == b'[' && (i == 0 || bytes[i - 1] != b'\\' || (i >= 2 && bytes[i - 2] == b'\\')) {
let mut depth: u32 = 1;
let mut j = i + 1;
while j < len && depth > 0 {
match bytes[j] {
b'\\' => {
j += 1; }
b'[' => depth += 1,
b']' => depth -= 1,
_ => {}
}
j += 1;
}
if depth == 0 && j < len {
if bytes[j] == b'(' {
let url_start = j;
let mut paren_depth: u32 = 1;
let mut k = j + 1;
while k < len && paren_depth > 0 {
match bytes[k] {
b'\\' => {
k += 1; }
b'(' => paren_depth += 1,
b')' => paren_depth -= 1,
_ => {}
}
k += 1;
}
if paren_depth == 0 {
if pos > url_start && pos < k {
return true;
}
i = k;
continue;
}
} else if bytes[j] == b'[' {
let ref_start = j;
let mut ref_depth: u32 = 1;
let mut k = j + 1;
while k < len && ref_depth > 0 {
match bytes[k] {
b'\\' => {
k += 1;
}
b'[' => ref_depth += 1,
b']' => ref_depth -= 1,
_ => {}
}
k += 1;
}
if ref_depth == 0 {
if pos > ref_start && pos < k {
return true;
}
i = k;
continue;
}
}
}
}
i += 1;
}
false
}
fn is_in_backtick_code_in_line(line: &str, pos: usize) -> bool {
let bytes = line.as_bytes();
let len = bytes.len();
let mut i = 0;
while i < len {
if bytes[i] == b'`' {
let open_start = i;
while i < len && bytes[i] == b'`' {
i += 1;
}
let tick_len = i - open_start;
while i < len {
if bytes[i] == b'`' {
let close_start = i;
while i < len && bytes[i] == b'`' {
i += 1;
}
if i - close_start == tick_len {
let content_start = open_start + tick_len;
let content_end = close_start;
if pos >= content_start && pos < content_end {
return true;
}
break;
}
} else {
i += 1;
}
}
} else {
i += 1;
}
}
false
}
fn is_word_boundary_char(c: char) -> bool {
!c.is_alphanumeric()
}
fn is_at_word_boundary(content: &str, pos: usize, is_start: bool) -> bool {
if is_start {
if pos == 0 {
return true;
}
match content[..pos].chars().next_back() {
None => true,
Some(c) => Self::is_word_boundary_char(c),
}
} else {
if pos >= content.len() {
return true;
}
match content[pos..].chars().next() {
None => true,
Some(c) => Self::is_word_boundary_char(c),
}
}
}
fn is_in_path_like_token(line: &str, match_start: usize, fm_value: (usize, usize)) -> bool {
let (value_start, value_end) = fm_value;
if match_start < value_start || match_start >= value_end {
return false;
}
let quoted_words: Vec<&str> = if frontmatter_values::value_is_quoted(line, value_start) {
line[value_start..value_end].split_whitespace().collect()
} else {
Vec::new()
};
let is_single_quoted_path = !quoted_words.is_empty() && quoted_words.iter().all(|word| word.contains('/'));
let is_multi_word_collapse = is_single_quoted_path && quoted_words.len() > 1;
let (raw_start, raw_end) = if is_single_quoted_path {
(value_start, value_end)
} else {
frontmatter_values::token_bounds(line, match_start, value_start, value_end)
};
let (start, end) = frontmatter_values::trim_token_bounds(line, raw_start, raw_end);
if match_start < start || match_start >= end {
return false;
}
let token = &line[start..end];
if !token.contains('/') {
return false;
}
if token.starts_with('/') || token.starts_with("./") || token.starts_with("../") || token.starts_with("~/") {
return true;
}
if token.rsplit('/').next().is_some_and(|seg| seg.contains('.')) {
return true;
}
if is_multi_word_collapse {
return false;
}
let sole_value = {
let (ts, te) = frontmatter_values::trim_token_bounds(line, value_start, value_end);
ts == start && te == end
};
sole_value && token.split('/').filter(|s| !s.is_empty()).count() >= 3
}
fn get_proper_name_for(&self, found_name: &str) -> Option<String> {
let found_lower = found_name.to_lowercase();
for name in &self.config.names {
let lower_name = name.to_lowercase();
let lower_name_no_dots = lower_name.replace('.', "");
if found_lower == lower_name || found_lower == lower_name_no_dots {
return Some(name.clone());
}
let ascii_normalized = Self::ascii_normalize(&lower_name);
let ascii_no_dots = ascii_normalized.replace('.', "");
if found_lower == ascii_normalized || found_lower == ascii_no_dots {
return Some(name.clone());
}
}
None
}
}
impl Rule for MD044ProperNames {
fn name(&self) -> &'static str {
"MD044"
}
fn description(&self) -> &'static str {
"Proper names should have the correct capitalization"
}
fn category(&self) -> RuleCategory {
RuleCategory::Other
}
fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
if self.config.names.is_empty() {
return true;
}
let content_lower = if ctx.content.is_ascii() {
ctx.content.to_ascii_lowercase()
} else {
ctx.content.to_lowercase()
};
!self.name_variants.iter().any(|name| content_lower.contains(name))
}
fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
let content = ctx.content;
if content.is_empty() || self.config.names.is_empty() || self.combined_pattern.is_none() {
return Ok(Vec::new());
}
let content_lower = if content.is_ascii() {
content.to_ascii_lowercase()
} else {
content.to_lowercase()
};
let has_potential_matches = self.name_variants.iter().any(|name| content_lower.contains(name));
if !has_potential_matches {
return Ok(Vec::new());
}
let violations = self.find_name_violations(content, ctx, &content_lower);
let warnings = violations
.into_iter()
.filter_map(|(line, column, found_name)| {
self.get_proper_name_for(&found_name).map(|proper_name| {
let line_start = ctx.line_start_byte(line).unwrap_or(0);
let byte_start = line_start + (column - 1);
let byte_end = byte_start + found_name.len();
let line_text = ctx.line_info(line).map_or("", |li| li.content(ctx.content));
let char_col = byte_to_char_count(line_text, column - 1);
LintWarning {
rule_name: Some(self.name().to_string()),
line,
column: char_col,
end_line: line,
end_column: char_col + found_name.chars().count(),
message: format!("Proper name '{found_name}' should be '{proper_name}'"),
severity: Severity::Warning,
fix: Some(Fix::new(byte_start..byte_end, proper_name)),
}
})
})
.collect();
Ok(warnings)
}
fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
if self.should_skip(ctx) {
return Ok(ctx.content.to_string());
}
let warnings = self.check(ctx)?;
if warnings.is_empty() {
return Ok(ctx.content.to_string());
}
let warnings =
crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
.map_err(crate::rule::LintError::InvalidInput)
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
crate::impl_rule_config_methods!(MD044Config);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::lint_context::LintContext;
fn create_context(content: &str) -> LintContext<'_> {
LintContext::new(content, crate::config::MarkdownFlavor::Standard, None)
}
fn field_map_for(content: &str) -> Vec<Option<String>> {
let ctx = create_context(content);
frontmatter_values::field_map(&ctx)
}
#[test]
fn test_field_map_nested_lines_inherit_top_level_key() {
let map = field_map_for("---\nseo:\n canonical: docs/a.md\n keywords:\n - myapp\ntitle: x\n---\n");
assert_eq!(map[2].as_deref(), Some("seo"));
assert_eq!(map[4].as_deref(), Some("seo"));
assert_eq!(map[5].as_deref(), Some("title"));
}
#[test]
fn test_field_map_block_scalar_bracket_does_not_swallow_next_key() {
let map = field_map_for("---\ndescription: |\n [myapp\ntitle: myapp\n---\n");
assert_eq!(map[2].as_deref(), Some("description"));
assert_eq!(
map[3].as_deref(),
Some("title"),
"an indent-0 key always starts a new key"
);
}
#[test]
fn test_field_map_quoted_key_with_colon() {
let map = field_map_for("---\n\"og:title\": myapp\n---\n");
assert_eq!(map[1].as_deref(), Some("og:title"));
}
#[test]
fn test_field_map_top_level_sequence_clears_attribution() {
let map = field_map_for("---\n- myapp\n---\n");
assert_eq!(map[1], None);
}
#[test]
fn test_field_map_toml_table_body_belongs_to_table_root() {
let map = field_map_for("+++\n[seo]\ncanonical = \"docs/a.md\"\n\n[[authors]]\nname = \"myapp\"\n+++\n");
assert_eq!(map[2].as_deref(), Some("seo"));
assert_eq!(map[5].as_deref(), Some("authors"));
}
#[test]
fn test_field_map_toml_dotted_assignment_uses_root() {
let map = field_map_for("+++\nseo.canonical = \"docs/a.md\"\n+++\n");
assert_eq!(map[1].as_deref(), Some("seo"));
}
#[test]
fn test_field_map_toml_array_continuation_inherits() {
let map = field_map_for("+++\nseo = [\n\"docs/guide/myapp\"\n]\n+++\n");
assert_eq!(map[2].as_deref(), Some("seo"));
}
#[test]
fn test_field_map_indent_zero_flow_continuation_is_not_attributed_to_parent() {
let map = field_map_for("---\nseo: [\n{name: myapp}\n]\n---\n");
assert_eq!(map[2].as_deref(), Some("{name"));
}
#[test]
fn test_field_map_toml_nested_array_inherits_and_title_not_corrupted() {
let map = field_map_for("+++\nmatrix = [\n [1, 2],\n [3, 4],\n]\ntitle = \"x\"\n+++\n");
assert_eq!(map[2].as_deref(), Some("matrix"), "nested array line inherits matrix");
assert_eq!(map[3].as_deref(), Some("matrix"), "nested array line inherits matrix");
assert_eq!(
map[5].as_deref(),
Some("title"),
"title must not inherit stale attribution from a closed nested array"
);
}
#[test]
fn test_field_map_toml_nested_array_last_element_without_trailing_comma() {
let map = field_map_for("+++\nmatrix = [\n [1, 2],\n [2]\n]\ntitle = \"x\"\n+++\n");
assert_eq!(
map[3].as_deref(),
Some("matrix"),
"last element without a trailing comma still inherits matrix"
);
assert_eq!(
map[5].as_deref(),
Some("title"),
"title must not inherit stale attribution from a closed nested array"
);
}
#[test]
fn test_field_map_toml_nested_array_then_real_table_header_non_regression_guard() {
let map = field_map_for("+++\nmatrix = [\n [1, 2],\n [3, 4],\n]\n\n[seo]\ncanonical = \"docs/a.md\"\n+++\n");
assert_eq!(map[6].as_deref(), Some("seo"), "real table header after a closed array");
assert_eq!(
map[7].as_deref(),
Some("seo"),
"table body still attributes to the table"
);
}
#[test]
fn test_field_map_toml_unclosed_array_resyncs_on_next_assignment() {
let map = field_map_for("+++\nmatrix = [\n [1, 2],\ntitle = \"x\"\n+++\n");
assert_eq!(
map[3].as_deref(),
Some("title"),
"title must resync even though the array was never closed"
);
}
#[test]
fn test_field_map_toml_column_zero_array_elements_inherit_and_title_not_corrupted() {
let map = field_map_for("+++\nmatrix = [\n[1, 2],\n[3, 4],\n]\ntitle = \"x\"\n+++\n");
assert_eq!(
map[2].as_deref(),
Some("matrix"),
"column-0 array element inherits matrix"
);
assert_eq!(
map[3].as_deref(),
Some("matrix"),
"column-0 array element inherits matrix"
);
assert_eq!(
map[5].as_deref(),
Some("title"),
"title must not inherit stale attribution from a misread array element"
);
}
#[test]
fn test_field_map_toml_column_zero_array_last_element_without_trailing_comma() {
let map = field_map_for("+++\nmatrix = [\n[1, 2],\n[2]\n]\ntitle = \"x\"\n+++\n");
assert_eq!(
map[3].as_deref(),
Some("matrix"),
"column-0 last element without a trailing comma still inherits matrix"
);
assert_eq!(
map[5].as_deref(),
Some("title"),
"title must not inherit stale attribution from a misread array element"
);
}
#[test]
fn test_correctly_capitalized_names() {
let rule = MD044ProperNames::new(
vec![
"JavaScript".to_string(),
"TypeScript".to_string(),
"Node.js".to_string(),
],
true,
);
let content = "This document uses JavaScript, TypeScript, and Node.js correctly.";
let ctx = create_context(content);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty(), "Should not flag correctly capitalized names");
}
#[test]
fn test_incorrectly_capitalized_names() {
let rule = MD044ProperNames::new(vec!["JavaScript".to_string(), "TypeScript".to_string()], true);
let content = "This document uses javascript and typescript incorrectly.";
let ctx = create_context(content);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 2, "Should flag two incorrect capitalizations");
assert_eq!(result[0].message, "Proper name 'javascript' should be 'JavaScript'");
assert_eq!(result[0].line, 1);
assert_eq!(result[0].column, 20);
assert_eq!(result[1].message, "Proper name 'typescript' should be 'TypeScript'");
assert_eq!(result[1].line, 1);
assert_eq!(result[1].column, 35);
}
#[test]
fn test_names_at_beginning_of_sentences() {
let rule = MD044ProperNames::new(vec!["JavaScript".to_string(), "Python".to_string()], true);
let content = "javascript is a great language. python is also popular.";
let ctx = create_context(content);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 2, "Should flag names at beginning of sentences");
assert_eq!(result[0].line, 1);
assert_eq!(result[0].column, 1);
assert_eq!(result[1].line, 1);
assert_eq!(result[1].column, 33);
}
#[test]
fn test_names_in_code_blocks_checked_by_default() {
let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
let content = r#"Here is some text with JavaScript.
```javascript
// This javascript should be checked
const lang = "javascript";
```
But this javascript should be flagged."#;
let ctx = create_context(content);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 3, "Should flag javascript inside and outside code blocks");
assert_eq!(result[0].line, 4);
assert_eq!(result[1].line, 5);
assert_eq!(result[2].line, 8);
}
#[test]
fn test_names_in_code_blocks_ignored_when_disabled() {
let rule = MD044ProperNames::new(
vec!["JavaScript".to_string()],
false, );
let content = r#"```
javascript in code block
```"#;
let ctx = create_context(content);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result.len(),
0,
"Should not flag javascript in code blocks when code_blocks is false"
);
}
#[test]
fn test_names_in_inline_code_checked_by_default() {
let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
let content = "This is `javascript` in inline code and javascript outside.";
let ctx = create_context(content);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 2, "Should flag javascript inside and outside inline code");
assert_eq!(result[0].column, 10); assert_eq!(result[1].column, 41); }
#[test]
fn test_multiple_names_in_same_line() {
let rule = MD044ProperNames::new(
vec!["JavaScript".to_string(), "TypeScript".to_string(), "React".to_string()],
true,
);
let content = "I use javascript, typescript, and react in my projects.";
let ctx = create_context(content);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 3, "Should flag all three incorrect names");
assert_eq!(result[0].message, "Proper name 'javascript' should be 'JavaScript'");
assert_eq!(result[1].message, "Proper name 'typescript' should be 'TypeScript'");
assert_eq!(result[2].message, "Proper name 'react' should be 'React'");
}
#[test]
fn test_case_sensitivity() {
let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
let content = "JAVASCRIPT, Javascript, javascript, and JavaScript variations.";
let ctx = create_context(content);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 3, "Should flag all incorrect case variations");
assert!(result.iter().all(|w| w.message.contains("should be 'JavaScript'")));
}
#[test]
fn test_configuration_with_custom_name_list() {
let config = MD044Config {
names: vec!["GitHub".to_string(), "GitLab".to_string(), "DevOps".to_string()],
code_blocks: true,
..Default::default()
};
let rule = MD044ProperNames::from_config_struct(config);
let content = "We use github, gitlab, and devops for our workflow.";
let ctx = create_context(content);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 3, "Should flag all custom names");
assert_eq!(result[0].message, "Proper name 'github' should be 'GitHub'");
assert_eq!(result[1].message, "Proper name 'gitlab' should be 'GitLab'");
assert_eq!(result[2].message, "Proper name 'devops' should be 'DevOps'");
}
#[test]
fn test_empty_configuration() {
let rule = MD044ProperNames::new(vec![], true);
let content = "This has javascript and typescript but no configured names.";
let ctx = create_context(content);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty(), "Should not flag anything with empty configuration");
}
#[test]
fn test_names_with_special_characters() {
let rule = MD044ProperNames::new(
vec!["Node.js".to_string(), "ASP.NET".to_string(), "C++".to_string()],
true,
);
let content = "We use nodejs, asp.net, ASP.NET, and c++ in our stack.";
let ctx = create_context(content);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 3, "Should handle special characters correctly");
let messages: Vec<&str> = result.iter().map(|w| w.message.as_str()).collect();
assert!(messages.contains(&"Proper name 'nodejs' should be 'Node.js'"));
assert!(messages.contains(&"Proper name 'asp.net' should be 'ASP.NET'"));
assert!(messages.contains(&"Proper name 'c++' should be 'C++'"));
}
#[test]
fn test_word_boundaries() {
let rule = MD044ProperNames::new(vec!["Java".to_string(), "Script".to_string()], true);
let content = "JavaScript is not java or script, but Java and Script are separate.";
let ctx = create_context(content);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 2, "Should respect word boundaries");
assert!(result.iter().any(|w| w.column == 19)); assert!(result.iter().any(|w| w.column == 27)); }
#[test]
fn test_fix_method() {
let rule = MD044ProperNames::new(
vec![
"JavaScript".to_string(),
"TypeScript".to_string(),
"Node.js".to_string(),
],
true,
);
let content = "I love javascript, typescript, and nodejs!";
let ctx = create_context(content);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, "I love JavaScript, TypeScript, and Node.js!");
}
#[test]
fn test_fix_multiple_occurrences() {
let rule = MD044ProperNames::new(vec!["Python".to_string()], true);
let content = "python is great. I use python daily. PYTHON is powerful.";
let ctx = create_context(content);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, "Python is great. I use Python daily. Python is powerful.");
}
#[test]
fn test_fix_checks_code_blocks_by_default() {
let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
let content = r#"I love javascript.
```
const lang = "javascript";
```
More javascript here."#;
let ctx = create_context(content);
let fixed = rule.fix(&ctx).unwrap();
let expected = r#"I love JavaScript.
```
const lang = "JavaScript";
```
More JavaScript here."#;
assert_eq!(fixed, expected);
}
#[test]
fn test_multiline_content() {
let rule = MD044ProperNames::new(vec!["Rust".to_string(), "Python".to_string()], true);
let content = r#"First line with rust.
Second line with python.
Third line with RUST and PYTHON."#;
let ctx = create_context(content);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 4, "Should flag all incorrect occurrences");
assert_eq!(result[0].line, 1);
assert_eq!(result[1].line, 2);
assert_eq!(result[2].line, 3);
assert_eq!(result[3].line, 3);
}
#[test]
fn test_default_config() {
let config = MD044Config::default();
assert!(config.names.is_empty());
assert!(!config.code_blocks);
assert!(config.html_elements);
assert!(config.html_comments);
}
#[test]
fn test_default_config_checks_html_comments() {
let config = MD044Config {
names: vec!["JavaScript".to_string()],
..MD044Config::default()
};
let rule = MD044ProperNames::from_config_struct(config);
let content = "# Guide\n\n<!-- javascript mentioned here -->\n";
let ctx = create_context(content);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1, "Default config should check HTML comments");
assert_eq!(result[0].line, 3);
}
#[test]
fn test_default_config_skips_code_blocks() {
let config = MD044Config {
names: vec!["JavaScript".to_string()],
..MD044Config::default()
};
let rule = MD044ProperNames::from_config_struct(config);
let content = "# Guide\n\n```\njavascript in code\n```\n";
let ctx = create_context(content);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 0, "Default config should skip code blocks");
}
#[test]
fn test_standalone_html_comment_checked() {
let config = MD044Config {
names: vec!["Test".to_string()],
..MD044Config::default()
};
let rule = MD044ProperNames::from_config_struct(config);
let content = "# Heading\n\n<!-- this is a test example -->\n";
let ctx = create_context(content);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1, "Should flag proper name in standalone HTML comment");
assert_eq!(result[0].line, 3);
}
#[test]
fn test_inline_config_comments_not_flagged() {
let config = MD044Config {
names: vec!["RUMDL".to_string()],
..MD044Config::default()
};
let rule = MD044ProperNames::from_config_struct(config);
let content = "<!-- rumdl-disable MD044 -->\nSome rumdl text here.\n<!-- rumdl-enable MD044 -->\n<!-- markdownlint-disable -->\nMore rumdl text.\n<!-- markdownlint-enable -->\n";
let ctx = create_context(content);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 2, "Should only flag body lines, not config comments");
assert_eq!(result[0].line, 2);
assert_eq!(result[1].line, 5);
}
#[test]
fn test_html_comment_skipped_when_disabled() {
let config = MD044Config {
names: vec!["Test".to_string()],
code_blocks: true,
html_comments: false,
..Default::default()
};
let rule = MD044ProperNames::from_config_struct(config);
let content = "# Heading\n\n<!-- this is a test example -->\n\nRegular test here.\n";
let ctx = create_context(content);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result.len(),
1,
"Should only flag 'test' outside HTML comment when html_comments=false"
);
assert_eq!(result[0].line, 5);
}
#[test]
fn test_fix_corrects_html_comment_content() {
let config = MD044Config {
names: vec!["JavaScript".to_string()],
..MD044Config::default()
};
let rule = MD044ProperNames::from_config_struct(config);
let content = "# Guide\n\n<!-- javascript mentioned here -->\n";
let ctx = create_context(content);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, "# Guide\n\n<!-- JavaScript mentioned here -->\n");
}
#[test]
fn test_fix_does_not_modify_inline_config_comments() {
let config = MD044Config {
names: vec!["RUMDL".to_string()],
..MD044Config::default()
};
let rule = MD044ProperNames::from_config_struct(config);
let content = "<!-- rumdl-disable -->\nSome rumdl text.\n<!-- rumdl-enable -->\n";
let ctx = create_context(content);
let fixed = rule.fix(&ctx).unwrap();
assert!(fixed.contains("<!-- rumdl-disable -->"));
assert!(fixed.contains("<!-- rumdl-enable -->"));
assert!(
fixed.contains("Some rumdl text."),
"Line inside rumdl-disable block should not be modified by fix()"
);
}
#[test]
fn test_fix_respects_inline_disable_partial() {
let config = MD044Config {
names: vec!["RUMDL".to_string()],
..MD044Config::default()
};
let rule = MD044ProperNames::from_config_struct(config);
let content =
"<!-- rumdl-disable MD044 -->\nSome rumdl text.\n<!-- rumdl-enable MD044 -->\n\nSome rumdl text outside.\n";
let ctx = create_context(content);
let fixed = rule.fix(&ctx).unwrap();
assert!(
fixed.contains("Some rumdl text.\n<!-- rumdl-enable"),
"Line inside disable block should not be modified"
);
assert!(
fixed.contains("Some RUMDL text outside."),
"Line outside disable block should be fixed"
);
}
#[test]
fn test_performance_with_many_names() {
let mut names = vec![];
for i in 0..50 {
names.push(format!("ProperName{i}"));
}
let rule = MD044ProperNames::new(names, true);
let content = "This has propername0, propername25, and propername49 incorrectly.";
let ctx = create_context(content);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 3, "Should handle many configured names efficiently");
}
#[test]
fn test_large_name_count_performance() {
let names = (0..1000).map(|i| format!("ProperName{i}")).collect::<Vec<_>>();
let rule = MD044ProperNames::new(names, true);
assert!(rule.combined_pattern.is_some());
let content = "This has propername0 and propername999 in it.";
let ctx = create_context(content);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 2, "Should handle 1000 names without issues");
}
#[test]
fn test_cache_behavior() {
let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
let content = "Using javascript here.";
let ctx = create_context(content);
let result1 = rule.check(&ctx).unwrap();
assert_eq!(result1.len(), 1);
let result2 = rule.check(&ctx).unwrap();
assert_eq!(result2.len(), 1);
assert_eq!(result1[0].line, result2[0].line);
assert_eq!(result1[0].column, result2[0].column);
}
#[test]
fn test_html_comments_not_checked_when_disabled() {
let config = MD044Config {
names: vec!["JavaScript".to_string()],
code_blocks: true, html_comments: false, ..Default::default()
};
let rule = MD044ProperNames::from_config_struct(config);
let content = r#"Regular javascript here.
<!-- This javascript in HTML comment should be ignored -->
More javascript outside."#;
let ctx = create_context(content);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 2, "Should only flag javascript outside HTML comments");
assert_eq!(result[0].line, 1);
assert_eq!(result[1].line, 3);
}
#[test]
fn test_html_comments_checked_when_enabled() {
let config = MD044Config {
names: vec!["JavaScript".to_string()],
code_blocks: true, ..Default::default()
};
let rule = MD044ProperNames::from_config_struct(config);
let content = r#"Regular javascript here.
<!-- This javascript in HTML comment should be checked -->
More javascript outside."#;
let ctx = create_context(content);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result.len(),
3,
"Should flag all javascript occurrences including in HTML comments"
);
}
#[test]
fn test_indented_html_comment_escapes_via_link_and_backticks() {
let config = MD044Config {
names: vec!["Test".to_string()],
..Default::default()
};
let rule = MD044ProperNames::from_config_struct(config);
let content = "<!-- see the [relevant page](test.md). -->\n<!-- see `test.md` -->\n <!-- see the [relevant page](test.md). -->\n <!-- see `test.md` -->\n";
let ctx = create_context(content);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"'test' inside a link URL or backticks must be ignored in both column-0 and indented comments, got: {result:?}"
);
}
#[test]
fn test_indented_html_comment_still_checks_bare_prose() {
let config = MD044Config {
names: vec!["Test".to_string()],
..Default::default()
};
let rule = MD044ProperNames::from_config_struct(config);
let content = " <!-- this is a test comment -->\n";
let ctx = create_context(content);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result.len(),
1,
"bare 'test' in an indented comment is still a violation"
);
assert_eq!(result[0].line, 1);
}
#[test]
fn test_multiline_html_comments() {
let config = MD044Config {
names: vec!["Python".to_string(), "JavaScript".to_string()],
code_blocks: true, html_comments: false, ..Default::default()
};
let rule = MD044ProperNames::from_config_struct(config);
let content = r#"Regular python here.
<!--
This is a multiline comment
with javascript and python
that should be ignored
-->
More javascript outside."#;
let ctx = create_context(content);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 2, "Should only flag names outside HTML comments");
assert_eq!(result[0].line, 1); assert_eq!(result[1].line, 7); }
#[test]
fn test_fix_preserves_html_comments_when_disabled() {
let config = MD044Config {
names: vec!["JavaScript".to_string()],
code_blocks: true, html_comments: false, ..Default::default()
};
let rule = MD044ProperNames::from_config_struct(config);
let content = r#"javascript here.
<!-- javascript in comment -->
More javascript."#;
let ctx = create_context(content);
let fixed = rule.fix(&ctx).unwrap();
let expected = r#"JavaScript here.
<!-- javascript in comment -->
More JavaScript."#;
assert_eq!(
fixed, expected,
"Should not fix names inside HTML comments when disabled"
);
}
#[test]
fn test_proper_names_in_link_text_are_flagged() {
let rule = MD044ProperNames::new(
vec!["JavaScript".to_string(), "Node.js".to_string(), "Python".to_string()],
true,
);
let content = r#"Check this [javascript documentation](https://javascript.info) for info.
Visit [node.js homepage](https://nodejs.org) and [python tutorial](https://python.org).
Real javascript should be flagged.
Also see the [typescript guide][ts-ref] for more.
Real python should be flagged too.
[ts-ref]: https://typescript.org/handbook"#;
let ctx = create_context(content);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 5, "Expected 5 warnings: 3 in link text + 2 standalone");
let line_1_warnings: Vec<_> = result.iter().filter(|w| w.line == 1).collect();
assert_eq!(line_1_warnings.len(), 1);
assert!(
line_1_warnings[0]
.message
.contains("'javascript' should be 'JavaScript'")
);
let line_3_warnings: Vec<_> = result.iter().filter(|w| w.line == 3).collect();
assert_eq!(line_3_warnings.len(), 2);
assert!(result.iter().any(|w| w.line == 5 && w.message.contains("'javascript'")));
assert!(result.iter().any(|w| w.line == 9 && w.message.contains("'python'")));
}
#[test]
fn test_link_urls_not_flagged() {
let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
let content = r#"[Link Text](https://javascript.info/guide)"#;
let ctx = create_context(content);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty(), "URLs should not be checked for proper names");
}
#[test]
fn test_bare_urls_not_flagged() {
let rule = MD044ProperNames::new(vec!["Foo".to_string(), "JavaScript".to_string()], true);
let content =
"https://foo.com\n\nSee https://javascript.info/foo/guide for details.\n\nMail foo@foo.com about it.\n";
let ctx = create_context(content);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Bare URLs and emails should not be checked for proper names: {result:?}"
);
}
#[test]
fn test_prose_around_bare_url_still_flagged() {
let rule = MD044ProperNames::new(vec!["Foo".to_string()], true);
let content = "Use foo at https://foo.com because foo is great.\n";
let ctx = create_context(content);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result.len(),
2,
"Prose occurrences around a bare URL must still be flagged: {result:?}"
);
assert!(result.iter().all(|w| w.message.contains("'foo' should be 'Foo'")));
}
#[test]
fn test_proper_names_in_image_alt_text_are_flagged() {
let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
let content = r#"Here is a  image.
Real javascript should be flagged."#;
let ctx = create_context(content);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 2, "Expected 2 warnings: 1 in alt text + 1 standalone");
assert!(result[0].message.contains("'javascript' should be 'JavaScript'"));
assert!(result[0].line == 1); assert!(result[1].message.contains("'javascript' should be 'JavaScript'"));
assert!(result[1].line == 3); }
#[test]
fn test_image_urls_not_flagged() {
let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
let content = r#""#;
let ctx = create_context(content);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty(), "Image URLs should not be checked for proper names");
}
#[test]
fn test_reference_link_text_flagged_but_definition_not() {
let rule = MD044ProperNames::new(vec!["JavaScript".to_string(), "TypeScript".to_string()], true);
let content = r#"Check the [javascript guide][js-ref] for details.
Real javascript should be flagged.
[js-ref]: https://javascript.info/typescript/guide"#;
let ctx = create_context(content);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 2, "Expected 2 warnings: 1 in link text + 1 standalone");
assert!(result.iter().any(|w| w.line == 1 && w.message.contains("'javascript'")));
assert!(result.iter().any(|w| w.line == 3 && w.message.contains("'javascript'")));
}
#[test]
fn test_reference_definitions_not_flagged() {
let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
let content = r#"[js-ref]: https://javascript.info/guide"#;
let ctx = create_context(content);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty(), "Reference definitions should not be checked");
}
#[test]
fn test_wikilinks_text_is_flagged() {
let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
let content = r#"[[javascript]]
Regular javascript here.
[[JavaScript|display text]]"#;
let ctx = create_context(content);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 2, "Expected 2 warnings: 1 in WikiLink + 1 standalone");
assert!(
result
.iter()
.any(|w| w.line == 1 && w.column == 3 && w.message.contains("'javascript'"))
);
assert!(result.iter().any(|w| w.line == 3 && w.message.contains("'javascript'")));
}
#[test]
fn test_url_link_text_not_flagged() {
let rule = MD044ProperNames::new(vec!["GitHub".to_string()], true);
let content = r#"[https://github.com/org/repo](https://github.com/org/repo)
[http://github.com/org/repo](http://github.com/org/repo)
[www.github.com/org/repo](https://www.github.com/org/repo)"#;
let ctx = create_context(content);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"URL-like link text should not be flagged, got: {result:?}"
);
}
#[test]
fn test_url_link_text_with_leading_space_not_flagged() {
let rule = MD044ProperNames::new(vec!["GitHub".to_string()], true);
let content = r#"[ https://github.com/org/repo](https://github.com/org/repo)"#;
let ctx = create_context(content);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"URL-like link text with leading space should not be flagged, got: {result:?}"
);
}
#[test]
fn test_url_link_text_uppercase_scheme_not_flagged() {
let rule = MD044ProperNames::new(vec!["GitHub".to_string()], true);
let content = r#"[HTTPS://GITHUB.COM/org/repo](https://github.com/org/repo)"#;
let ctx = create_context(content);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"URL-like link text with uppercase scheme should not be flagged, got: {result:?}"
);
}
#[test]
fn test_non_url_link_text_still_flagged() {
let rule = MD044ProperNames::new(vec!["GitHub".to_string()], true);
let content = r#"[github.com/org/repo](https://github.com/org/repo)
[Visit github](https://github.com/org/repo)
[//github.com/org/repo](//github.com/org/repo)
[ftp://github.com/org/repo](ftp://github.com/org/repo)"#;
let ctx = create_context(content);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result.len(),
1,
"Only prose link text should be flagged, got: {result:?}"
);
assert!(
result.iter().any(|w| w.line == 3),
"Expected 'Visit github' on line 3 to be flagged"
);
}
#[test]
fn test_url_link_text_fix_not_applied() {
let rule = MD044ProperNames::new(vec!["GitHub".to_string()], true);
let content = "[https://github.com/org/repo](https://github.com/org/repo)\n";
let ctx = create_context(content);
let result = rule.fix(&ctx).unwrap();
assert_eq!(result, content, "Fix should not modify URL-like link text");
}
#[test]
fn test_mixed_url_and_regular_link_text() {
let rule = MD044ProperNames::new(vec!["GitHub".to_string()], true);
let content = r#"[https://github.com/org/repo](https://github.com/org/repo)
Visit [github documentation](https://github.com/docs) for details.
[www.github.com/pricing](https://www.github.com/pricing)"#;
let ctx = create_context(content);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result.len(),
1,
"Only non-URL link text should be flagged, got: {result:?}"
);
assert_eq!(result[0].line, 3);
}
#[test]
fn test_html_attribute_values_not_flagged() {
let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
let content = "# Heading\n\ntest\n\n<img src=\"www.example.test/test_image.png\">\n";
let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
let line5_violations: Vec<_> = result.iter().filter(|w| w.line == 5).collect();
assert!(
line5_violations.is_empty(),
"Should not flag anything inside HTML tag attributes: {line5_violations:?}"
);
let line3_violations: Vec<_> = result.iter().filter(|w| w.line == 3).collect();
assert_eq!(line3_violations.len(), 1, "Plain 'test' on line 3 should be flagged");
}
#[test]
fn test_html_text_content_still_flagged() {
let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
let content = "# Heading\n\n<a href=\"https://example.test/page\">test link</a>\n";
let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result.len(),
1,
"Should flag only 'test' in anchor text, not in href: {result:?}"
);
assert_eq!(result[0].column, 37, "Should flag col 37 ('test link' in anchor text)");
}
#[test]
fn test_html_attribute_various_not_flagged() {
let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
let content = concat!(
"# Heading\n\n",
"<img src=\"test.png\" alt=\"test image\">\n",
"<span class=\"test-class\" data-test=\"value\">test content</span>\n",
);
let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result.len(),
1,
"Should flag only 'test content' between tags: {result:?}"
);
assert_eq!(result[0].line, 4);
}
#[test]
fn test_plain_text_underscore_boundary_unchanged() {
let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
let content = "# Heading\n\ntest_image is here and just_test ends here\n";
let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result.len(),
2,
"Should flag 'test' in both 'test_image' and 'just_test': {result:?}"
);
let cols: Vec<usize> = result.iter().map(|w| w.column).collect();
assert!(cols.contains(&1), "Should flag col 1 (test_image): {cols:?}");
assert!(cols.contains(&29), "Should flag col 29 (just_test): {cols:?}");
}
#[test]
fn test_frontmatter_yaml_keys_not_flagged() {
let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
let content = "---\ntitle: Heading\ntest: Some Test value\n---\n\nTest\n";
let ctx = create_context(content);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Should not flag YAML keys or correctly capitalized values: {result:?}"
);
}
#[test]
fn test_frontmatter_yaml_values_flagged() {
let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
let content = "---\ntitle: Heading\nkey: a test value\n---\n\nTest\n";
let ctx = create_context(content);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1, "Should flag 'test' in YAML value: {result:?}");
assert_eq!(result[0].line, 3);
assert_eq!(result[0].column, 8); }
#[test]
fn test_frontmatter_key_matches_name_not_flagged() {
let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
let content = "---\ntest: other value\n---\n\nBody text\n";
let ctx = create_context(content);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Should not flag YAML key that matches configured name: {result:?}"
);
}
#[test]
fn test_frontmatter_empty_value_not_flagged() {
let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
let content = "---\ntest:\ntest: \n---\n\nBody text\n";
let ctx = create_context(content);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Should not flag YAML keys with empty values: {result:?}"
);
}
#[test]
fn test_frontmatter_nested_yaml_key_not_flagged() {
let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
let content = "---\nparent:\n test: nested value\n---\n\nBody text\n";
let ctx = create_context(content);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty(), "Should not flag nested YAML keys: {result:?}");
}
#[test]
fn test_frontmatter_list_items_checked() {
let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
let content = "---\ntags:\n - test\n - other\n---\n\nBody text\n";
let ctx = create_context(content);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1, "Should flag 'test' in YAML list item: {result:?}");
assert_eq!(result[0].line, 3);
}
#[test]
fn test_frontmatter_value_with_multiple_colons() {
let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
let content = "---\ntest: description: a test thing\n---\n\nBody text\n";
let ctx = create_context(content);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result.len(),
1,
"Should flag 'test' in value after first colon: {result:?}"
);
assert_eq!(result[0].line, 2);
assert!(result[0].column > 6, "Violation column should be in value portion");
}
#[test]
fn test_frontmatter_does_not_affect_body() {
let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
let content = "---\ntitle: Heading\n---\n\ntest should be flagged here\n";
let ctx = create_context(content);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1, "Should flag 'test' in body text: {result:?}");
assert_eq!(result[0].line, 5);
}
#[test]
fn test_frontmatter_fix_corrects_values_preserves_keys() {
let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
let content = "---\ntest: a test value\n---\n\ntest here\n";
let ctx = create_context(content);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, "---\ntest: a Test value\n---\n\nTest here\n");
}
#[test]
fn test_frontmatter_multiword_value_flagged() {
let rule = MD044ProperNames::new(vec!["JavaScript".to_string(), "TypeScript".to_string()], true);
let content = "---\ndescription: Learn javascript and typescript\n---\n\nBody\n";
let ctx = create_context(content);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 2, "Should flag both names in YAML value: {result:?}");
assert!(result.iter().all(|w| w.line == 2));
}
#[test]
fn test_frontmatter_yaml_comments_not_checked() {
let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
let content = "---\n# test comment\ntitle: Heading\n---\n\nBody text\n";
let ctx = create_context(content);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty(), "Should not flag names in YAML comments: {result:?}");
}
#[test]
fn test_frontmatter_delimiters_not_checked() {
let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
let content = "---\ntitle: Heading\n---\n\ntest here\n";
let ctx = create_context(content);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1, "Should only flag body text: {result:?}");
assert_eq!(result[0].line, 5);
}
#[test]
fn test_frontmatter_continuation_lines_checked() {
let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
let content = "---\ndescription: >\n a test value\n continued here\n---\n\nBody\n";
let ctx = create_context(content);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1, "Should flag 'test' in continuation line: {result:?}");
assert_eq!(result[0].line, 3);
}
#[test]
fn test_frontmatter_quoted_values_checked() {
let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
let content = "---\ntitle: \"a test title\"\n---\n\nBody\n";
let ctx = create_context(content);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1, "Should flag 'test' in quoted YAML value: {result:?}");
assert_eq!(result[0].line, 2);
}
#[test]
fn test_frontmatter_single_quoted_values_checked() {
let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
let content = "---\ntitle: 'a test title'\n---\n\nBody\n";
let ctx = create_context(content);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result.len(),
1,
"Should flag 'test' in single-quoted YAML value: {result:?}"
);
assert_eq!(result[0].line, 2);
}
#[test]
fn test_frontmatter_fix_multiword_values() {
let rule = MD044ProperNames::new(vec!["JavaScript".to_string(), "TypeScript".to_string()], true);
let content = "---\ndescription: Learn javascript and typescript\n---\n\nBody\n";
let ctx = create_context(content);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(
fixed,
"---\ndescription: Learn JavaScript and TypeScript\n---\n\nBody\n"
);
}
#[test]
fn test_frontmatter_fix_preserves_yaml_structure() {
let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
let content = "---\ntags:\n - test\n - other\ntitle: a test doc\n---\n\ntest body\n";
let ctx = create_context(content);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(
fixed,
"---\ntags:\n - Test\n - other\ntitle: a Test doc\n---\n\nTest body\n"
);
}
#[test]
fn test_frontmatter_toml_delimiters_not_checked() {
let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
let content = "+++\ntitle = \"a test title\"\n+++\n\ntest body\n";
let ctx = create_context(content);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 2, "Should flag TOML value and body: {result:?}");
let fm_violations: Vec<_> = result.iter().filter(|w| w.line == 2).collect();
assert_eq!(fm_violations.len(), 1, "Should flag 'test' in TOML value: {result:?}");
let body_violations: Vec<_> = result.iter().filter(|w| w.line == 5).collect();
assert_eq!(body_violations.len(), 1, "Should flag body 'test': {result:?}");
}
#[test]
fn test_frontmatter_toml_key_not_flagged() {
let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
let content = "+++\ntest = \"other value\"\n+++\n\nBody text\n";
let ctx = create_context(content);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Should not flag TOML key that matches configured name: {result:?}"
);
}
#[test]
fn test_frontmatter_toml_fix_preserves_keys() {
let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
let content = "+++\ntest = \"a test value\"\n+++\n\ntest here\n";
let ctx = create_context(content);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, "+++\ntest = \"a Test value\"\n+++\n\nTest here\n");
}
#[test]
fn test_frontmatter_list_item_mapping_key_not_flagged() {
let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
let content = "---\nitems:\n - test: nested value\n---\n\nBody text\n";
let ctx = create_context(content);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Should not flag YAML key in list-item mapping: {result:?}"
);
}
#[test]
fn test_frontmatter_list_item_mapping_value_flagged() {
let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
let content = "---\nitems:\n - key: a test value\n---\n\nBody text\n";
let ctx = create_context(content);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result.len(),
1,
"Should flag 'test' in list-item mapping value: {result:?}"
);
assert_eq!(result[0].line, 3);
}
#[test]
fn test_frontmatter_bare_list_item_still_flagged() {
let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
let content = "---\ntags:\n - test\n - other\n---\n\nBody text\n";
let ctx = create_context(content);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1, "Should flag 'test' in bare list item: {result:?}");
assert_eq!(result[0].line, 3);
}
#[test]
fn test_frontmatter_flow_mapping_not_flagged() {
let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
let content = "---\nflow_map: {test: value, other: test}\n---\n\nBody text\n";
let ctx = create_context(content);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Should not flag names inside flow mappings: {result:?}"
);
}
#[test]
fn test_frontmatter_flow_sequence_not_flagged() {
let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
let content = "---\nitems: [test, other, test]\n---\n\nBody text\n";
let ctx = create_context(content);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Should not flag names inside flow sequences: {result:?}"
);
}
#[test]
fn test_frontmatter_list_item_mapping_fix_preserves_key() {
let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
let content = "---\nitems:\n - test: a test value\n---\n\ntest here\n";
let ctx = create_context(content);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, "---\nitems:\n - test: a Test value\n---\n\nTest here\n");
}
#[test]
fn test_frontmatter_backtick_code_not_flagged() {
let config = MD044Config {
names: vec!["GoodApplication".to_string()],
code_blocks: false,
..MD044Config::default()
};
let rule = MD044ProperNames::from_config_struct(config);
let content = "---\ntitle: \"`goodapplication` CLI\"\n---\n\nIntroductory `goodapplication` CLI text.\n";
let ctx = create_context(content);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Should not flag names inside backticks in frontmatter or body: {result:?}"
);
}
#[test]
fn test_frontmatter_unquoted_backtick_code_not_flagged() {
let config = MD044Config {
names: vec!["GoodApplication".to_string()],
code_blocks: false,
..MD044Config::default()
};
let rule = MD044ProperNames::from_config_struct(config);
let content = "---\ntitle: `goodapplication` CLI\n---\n\nIntroductory `goodapplication` CLI text.\n";
let ctx = create_context(content);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Should not flag names inside backticks in unquoted YAML frontmatter: {result:?}"
);
}
#[test]
fn test_frontmatter_bare_name_still_flagged_with_backtick_nearby() {
let config = MD044Config {
names: vec!["GoodApplication".to_string()],
code_blocks: false,
..MD044Config::default()
};
let rule = MD044ProperNames::from_config_struct(config);
let content = "---\ntitle: goodapplication `goodapplication` CLI\n---\n\nBody\n";
let ctx = create_context(content);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result.len(),
1,
"Should flag bare name but not backtick-wrapped name: {result:?}"
);
assert_eq!(result[0].line, 2);
assert_eq!(result[0].column, 8); }
#[test]
fn test_frontmatter_backtick_code_with_code_blocks_true() {
let config = MD044Config {
names: vec!["GoodApplication".to_string()],
code_blocks: true,
..MD044Config::default()
};
let rule = MD044ProperNames::from_config_struct(config);
let content = "---\ntitle: \"`goodapplication` CLI\"\n---\n\nBody\n";
let ctx = create_context(content);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result.len(),
1,
"Should flag backtick-wrapped name when code_blocks=true: {result:?}"
);
assert_eq!(result[0].line, 2);
}
#[test]
fn test_frontmatter_fix_preserves_backtick_code() {
let config = MD044Config {
names: vec!["GoodApplication".to_string()],
code_blocks: false,
..MD044Config::default()
};
let rule = MD044ProperNames::from_config_struct(config);
let content = "---\ntitle: \"`goodapplication` CLI\"\n---\n\nIntroductory `goodapplication` CLI text.\n";
let ctx = create_context(content);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(
fixed, content,
"Fix should not modify names inside backticks in frontmatter"
);
}
fn rule_ignoring(names: &[&str], ignore: &[&str]) -> MD044ProperNames {
MD044ProperNames::from_config_struct(MD044Config {
names: names.iter().map(ToString::to_string).collect(),
ignore_frontmatter_fields: Some(ignore.iter().map(ToString::to_string).collect()),
..Default::default()
})
}
#[test]
fn test_ignore_frontmatter_field_suppresses_only_that_field() {
let content = "---\ntitle: Heading for myapp\nslug: myapp-guide\n---\n";
let rule = rule_ignoring(&["MyApp"], &["slug"]);
let result = rule.check(&create_context(content)).unwrap();
assert_eq!(result.len(), 1, "only title is flagged: {result:?}");
assert_eq!(result[0].line, 2);
}
#[test]
fn test_ignore_frontmatter_field_is_case_insensitive() {
let content = "---\nSlug: myapp-guide\n---\n";
let rule = rule_ignoring(&["MyApp"], &["SLUG"]);
assert!(rule.check(&create_context(content)).unwrap().is_empty());
}
#[test]
fn test_ignore_frontmatter_field_covers_nested_subtree() {
let content = "---\nseo:\n canonical: myapp\n keywords:\n - myapp\n---\n";
let rule = rule_ignoring(&["MyApp"], &["seo"]);
assert!(rule.check(&create_context(content)).unwrap().is_empty());
}
#[test]
fn test_ignore_frontmatter_field_does_not_affect_body() {
let content = "---\nslug: myapp\n---\n\nBody mentions myapp.\n";
let rule = rule_ignoring(&["MyApp"], &["slug"]);
let result = rule.check(&create_context(content)).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].line, 5);
}
#[test]
fn test_ignore_frontmatter_field_toml_table() {
let content = "+++\n[seo]\ncanonical = \"myapp\"\n+++\n";
let rule = rule_ignoring(&["MyApp"], &["seo"]);
assert!(rule.check(&create_context(content)).unwrap().is_empty());
}
#[test]
fn test_angle_bracket_url_in_html_comment_not_flagged() {
let config = MD044Config {
names: vec!["Test".to_string()],
..MD044Config::default()
};
let rule = MD044ProperNames::from_config_struct(config);
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";
let ctx = create_context(content);
let result = rule.check(&ctx).unwrap();
let line8_warnings: Vec<_> = result.iter().filter(|w| w.line == 8).collect();
assert!(
line8_warnings.is_empty(),
"Should not flag names inside angle-bracket URLs in HTML comments: {line8_warnings:?}"
);
}
#[test]
fn test_bare_url_in_html_comment_still_flagged() {
let config = MD044Config {
names: vec!["Test".to_string()],
..MD044Config::default()
};
let rule = MD044ProperNames::from_config_struct(config);
let content = "<!-- This is a test https://www.example.test -->\n";
let ctx = create_context(content);
let result = rule.check(&ctx).unwrap();
assert!(
!result.is_empty(),
"Should flag 'test' in prose text of HTML comment with bare URL"
);
}
#[test]
fn test_angle_bracket_url_in_regular_markdown_not_flagged() {
let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
let content = "<https://www.example.test>\n";
let ctx = create_context(content);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Should not flag names inside angle-bracket URLs in regular markdown: {result:?}"
);
}
#[test]
fn test_multiple_angle_bracket_urls_in_one_comment() {
let config = MD044Config {
names: vec!["Test".to_string()],
..MD044Config::default()
};
let rule = MD044ProperNames::from_config_struct(config);
let content = "<!-- See <https://test.example.com> and <https://www.example.test> for details -->\n";
let ctx = create_context(content);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Should not flag names inside multiple angle-bracket URLs: {result:?}"
);
}
#[test]
fn test_angle_bracket_non_url_still_flagged() {
assert!(
!MD044ProperNames::is_in_angle_bracket_url("<test> which is not a URL.", 1),
"is_in_angle_bracket_url should return false for non-URL angle brackets"
);
}
#[test]
fn test_angle_bracket_mailto_url_not_flagged() {
let config = MD044Config {
names: vec!["Test".to_string()],
..MD044Config::default()
};
let rule = MD044ProperNames::from_config_struct(config);
let content = "<!-- Contact <mailto:test@example.com> for help -->\n";
let ctx = create_context(content);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Should not flag names inside angle-bracket mailto URLs: {result:?}"
);
}
#[test]
fn test_angle_bracket_ftp_url_not_flagged() {
let config = MD044Config {
names: vec!["Test".to_string()],
..MD044Config::default()
};
let rule = MD044ProperNames::from_config_struct(config);
let content = "<!-- Download from <ftp://test.example.com/file> -->\n";
let ctx = create_context(content);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Should not flag names inside angle-bracket FTP URLs: {result:?}"
);
}
#[test]
fn test_angle_bracket_url_fix_preserves_url() {
let config = MD044Config {
names: vec!["Test".to_string()],
..MD044Config::default()
};
let rule = MD044ProperNames::from_config_struct(config);
let content = "<!-- test text <https://www.example.test> -->\n";
let ctx = create_context(content);
let fixed = rule.fix(&ctx).unwrap();
assert!(
fixed.contains("<https://www.example.test>"),
"Fix should preserve angle-bracket URLs: {fixed}"
);
assert!(
fixed.contains("Test text"),
"Fix should correct prose 'test' to 'Test': {fixed}"
);
}
#[test]
fn test_is_in_angle_bracket_url_helper() {
let line = "text <https://example.test> more text";
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));
assert!(MD044ProperNames::is_in_angle_bracket_url(
"<mailto:test@example.com>",
10
));
assert!(MD044ProperNames::is_in_angle_bracket_url(
"<ftp://test.example.com>",
10
));
}
#[test]
fn test_is_in_angle_bracket_url_uppercase_scheme() {
assert!(MD044ProperNames::is_in_angle_bracket_url(
"<HTTPS://test.example.com>",
10
));
assert!(MD044ProperNames::is_in_angle_bracket_url(
"<Http://test.example.com>",
10
));
}
#[test]
fn test_is_in_angle_bracket_url_uncommon_schemes() {
assert!(MD044ProperNames::is_in_angle_bracket_url(
"<ssh://test@example.com>",
10
));
assert!(MD044ProperNames::is_in_angle_bracket_url("<file:///test/path>", 10));
assert!(MD044ProperNames::is_in_angle_bracket_url("<data:text/plain;test>", 10));
}
#[test]
fn test_is_in_angle_bracket_url_unclosed() {
assert!(!MD044ProperNames::is_in_angle_bracket_url(
"<https://test.example.com",
10
));
}
#[test]
fn test_vale_inline_config_comments_not_flagged() {
let config = MD044Config {
names: vec!["Vale".to_string(), "JavaScript".to_string()],
..MD044Config::default()
};
let rule = MD044ProperNames::from_config_struct(config);
let content = "\
<!-- vale off -->
Some javascript text here.
<!-- vale on -->
<!-- vale Style.Rule = NO -->
More javascript text.
<!-- vale Style.Rule = YES -->
<!-- vale JavaScript.Grammar = NO -->
";
let ctx = create_context(content);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 2, "Should only flag body lines, not Vale config comments");
assert_eq!(result[0].line, 2);
assert_eq!(result[1].line, 5);
}
#[test]
fn test_remark_lint_inline_config_comments_not_flagged() {
let config = MD044Config {
names: vec!["JavaScript".to_string()],
..MD044Config::default()
};
let rule = MD044ProperNames::from_config_struct(config);
let content = "\
<!-- lint disable remark-lint-some-rule -->
Some javascript text here.
<!-- lint enable remark-lint-some-rule -->
<!-- lint ignore remark-lint-some-rule -->
More javascript text.
";
let ctx = create_context(content);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result.len(),
2,
"Should only flag body lines, not remark-lint config comments"
);
assert_eq!(result[0].line, 2);
assert_eq!(result[1].line, 5);
}
#[test]
fn test_fix_does_not_modify_vale_remark_lint_comments() {
let config = MD044Config {
names: vec!["JavaScript".to_string(), "Vale".to_string()],
..MD044Config::default()
};
let rule = MD044ProperNames::from_config_struct(config);
let content = "\
<!-- vale off -->
Some javascript text.
<!-- vale on -->
<!-- lint disable remark-lint-some-rule -->
More javascript text.
<!-- lint enable remark-lint-some-rule -->
";
let ctx = create_context(content);
let fixed = rule.fix(&ctx).unwrap();
assert!(fixed.contains("<!-- vale off -->"));
assert!(fixed.contains("<!-- vale on -->"));
assert!(fixed.contains("<!-- lint disable remark-lint-some-rule -->"));
assert!(fixed.contains("<!-- lint enable remark-lint-some-rule -->"));
assert!(fixed.contains("Some JavaScript text."));
assert!(fixed.contains("More JavaScript text."));
}
#[test]
fn test_mixed_tool_directives_all_skipped() {
let config = MD044Config {
names: vec!["JavaScript".to_string(), "Vale".to_string()],
..MD044Config::default()
};
let rule = MD044ProperNames::from_config_struct(config);
let content = "\
<!-- rumdl-disable MD044 -->
Some javascript text.
<!-- markdownlint-disable -->
More javascript text.
<!-- vale off -->
Even more javascript text.
<!-- lint disable some-rule -->
Final javascript text.
<!-- rumdl-enable MD044 -->
<!-- markdownlint-enable -->
<!-- vale on -->
<!-- lint enable some-rule -->
";
let ctx = create_context(content);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result.len(),
4,
"Should only flag body lines, not any tool directive comments"
);
assert_eq!(result[0].line, 2);
assert_eq!(result[1].line, 4);
assert_eq!(result[2].line, 6);
assert_eq!(result[3].line, 8);
}
#[test]
fn test_vale_remark_lint_edge_cases_not_matched() {
let config = MD044Config {
names: vec!["JavaScript".to_string(), "Vale".to_string()],
..MD044Config::default()
};
let rule = MD044ProperNames::from_config_struct(config);
let content = "\
<!-- vale -->
<!-- vale is a tool for writing -->
<!-- valedictorian javascript -->
<!-- linting javascript tips -->
<!-- vale javascript -->
<!-- lint your javascript code -->
";
let ctx = create_context(content);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result.len(),
7,
"Should flag proper names in non-directive HTML comments: got {result:?}"
);
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); }
#[test]
fn test_vale_style_directives_skipped() {
let config = MD044Config {
names: vec!["JavaScript".to_string(), "Vale".to_string()],
..MD044Config::default()
};
let rule = MD044ProperNames::from_config_struct(config);
let content = "\
<!-- vale style = MyStyle -->
<!-- vale styles = Style1, Style2 -->
<!-- vale MyRule.Name = YES -->
<!-- vale MyRule.Name = NO -->
Some javascript text.
";
let ctx = create_context(content);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result.len(),
1,
"Should only flag body lines, not Vale style/rule directives: got {result:?}"
);
assert_eq!(result[0].line, 5);
}
#[test]
fn test_backtick_code_single_backticks() {
let line = "hello `world` bye";
assert!(MD044ProperNames::is_in_backtick_code_in_line(line, 7));
assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 0));
assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 14));
}
#[test]
fn test_backtick_code_double_backticks() {
let line = "a ``code`` b";
assert!(MD044ProperNames::is_in_backtick_code_in_line(line, 4));
assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 0));
assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 11));
}
#[test]
fn test_backtick_code_unclosed() {
let line = "a `code b";
assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 3));
}
#[test]
fn test_backtick_code_mismatched_count() {
let line = "a `code`` b";
assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 3));
}
#[test]
fn test_backtick_code_multiple_spans() {
let line = "`first` and `second`";
assert!(MD044ProperNames::is_in_backtick_code_in_line(line, 1));
assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 8));
assert!(MD044ProperNames::is_in_backtick_code_in_line(line, 13));
}
#[test]
fn test_backtick_code_on_backtick_boundary() {
let line = "`code`";
assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 0));
assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 5));
assert!(MD044ProperNames::is_in_backtick_code_in_line(line, 1));
assert!(MD044ProperNames::is_in_backtick_code_in_line(line, 4));
}
#[test]
fn test_double_bracket_link_url_not_flagged() {
let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
let content = "[[rumdl]](https://github.com/rvben/rumdl)";
let ctx = create_context(content);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"URL inside [[text]](url) must not be flagged, got: {result:?}"
);
}
#[test]
fn test_double_bracket_link_url_not_fixed() {
let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
let content = "[[rumdl]](https://github.com/rvben/rumdl)\n";
let ctx = create_context(content);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(
fixed, content,
"fix() must leave the URL inside [[text]](url) unchanged"
);
}
#[test]
fn test_double_bracket_link_text_still_flagged() {
let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
let content = "[[github]](https://example.com)";
let ctx = create_context(content);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result.len(),
1,
"Incorrect name in [[text]] link text should still be flagged, got: {result:?}"
);
assert_eq!(result[0].message, "Proper name 'github' should be 'GitHub'");
}
#[test]
fn test_double_bracket_link_mixed_line() {
let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
let content = "See [[rumdl]](https://github.com/rvben/rumdl) and github for more.";
let ctx = create_context(content);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result.len(),
1,
"Only the standalone 'github' after the link should be flagged, got: {result:?}"
);
assert!(result[0].message.contains("'github'"));
assert_eq!(
result[0].column, 51,
"Flagged column should be the trailing 'github', not the one in the URL"
);
}
#[test]
fn test_regular_link_url_still_not_flagged() {
let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
let content = "[rumdl](https://github.com/rvben/rumdl)";
let ctx = create_context(content);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"URL inside regular [text](url) must still not be flagged, got: {result:?}"
);
}
#[test]
fn test_link_like_text_in_code_span_still_flagged_when_code_blocks_enabled() {
let rule = MD044ProperNames::new(vec!["GitHub".to_string()], true);
let content = "`[foo](https://github.com/org/repo)`";
let ctx = create_context(content);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result.len(),
1,
"Proper name inside a code span must be flagged when code-blocks=true, got: {result:?}"
);
assert!(result[0].message.contains("'github'"));
}
#[test]
fn test_malformed_link_not_treated_as_url() {
let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
let content = "See [rumdl](github repo) for details.";
let ctx = create_context(content);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result.len(),
1,
"Name inside malformed [text](url with spaces) must still be flagged, got: {result:?}"
);
assert!(result[0].message.contains("'github'"));
}
#[test]
fn test_wikilink_followed_by_prose_parens_still_flagged() {
let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
let content = "[[note]](github repo)";
let ctx = create_context(content);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result.len(),
1,
"Name inside [[wikilink]](prose with spaces) must still be flagged, got: {result:?}"
);
assert!(result[0].message.contains("'github'"));
}
#[test]
fn test_roundtrip_fix_then_check_basic() {
let rule = MD044ProperNames::new(
vec![
"JavaScript".to_string(),
"TypeScript".to_string(),
"Node.js".to_string(),
],
true,
);
let content = "I love javascript, typescript, and nodejs!";
let ctx = create_context(content);
let fixed = rule.fix(&ctx).unwrap();
let ctx2 = create_context(&fixed);
let warnings = rule.check(&ctx2).unwrap();
assert!(
warnings.is_empty(),
"Re-check after fix should produce zero warnings, got: {warnings:?}"
);
}
#[test]
fn test_roundtrip_fix_then_check_multiline() {
let rule = MD044ProperNames::new(vec!["Rust".to_string(), "Python".to_string()], true);
let content = "First line with rust.\nSecond line with python.\nThird line with RUST and PYTHON.\n";
let ctx = create_context(content);
let fixed = rule.fix(&ctx).unwrap();
let ctx2 = create_context(&fixed);
let warnings = rule.check(&ctx2).unwrap();
assert!(
warnings.is_empty(),
"Re-check after fix should produce zero warnings, got: {warnings:?}"
);
}
#[test]
fn test_roundtrip_fix_then_check_inline_config() {
let config = MD044Config {
names: vec!["RUMDL".to_string()],
..MD044Config::default()
};
let rule = MD044ProperNames::from_config_struct(config);
let content =
"<!-- rumdl-disable MD044 -->\nSome rumdl text.\n<!-- rumdl-enable MD044 -->\n\nSome rumdl text outside.\n";
let ctx = create_context(content);
let fixed = rule.fix(&ctx).unwrap();
assert!(
fixed.contains("Some rumdl text.\n"),
"Disabled block text should be preserved"
);
assert!(
fixed.contains("Some RUMDL text outside."),
"Outside text should be fixed"
);
}
#[test]
fn test_roundtrip_fix_then_check_html_comments() {
let config = MD044Config {
names: vec!["JavaScript".to_string()],
..MD044Config::default()
};
let rule = MD044ProperNames::from_config_struct(config);
let content = "# Guide\n\n<!-- javascript mentioned here -->\n\njavascript outside\n";
let ctx = create_context(content);
let fixed = rule.fix(&ctx).unwrap();
let ctx2 = create_context(&fixed);
let warnings = rule.check(&ctx2).unwrap();
assert!(
warnings.is_empty(),
"Re-check after fix should produce zero warnings, got: {warnings:?}"
);
}
#[test]
fn test_roundtrip_no_op_when_correct() {
let rule = MD044ProperNames::new(vec!["JavaScript".to_string(), "TypeScript".to_string()], true);
let content = "This uses JavaScript and TypeScript correctly.\n";
let ctx = create_context(content);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, content, "Fix should be a no-op when content is already correct");
}
#[test]
fn test_bare_domain_link_text_not_flagged() {
let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
let content = "My site is [ravencentric.github.io](https://ravencentric.github.io).\n";
let ctx = create_context(content);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Should not flag 'github' in a bare-domain link text that matches the link URL: {result:?}"
);
}
#[test]
fn test_bare_domain_link_text_not_fixed() {
let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
let content = "My site is [ravencentric.github.io](https://ravencentric.github.io).\n";
let ctx = create_context(content);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(
fixed, content,
"fix() must not alter bare-domain link text that matches the destination URL"
);
}
#[test]
fn test_bare_domain_link_text_with_path_not_flagged() {
let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
let content = "Visit [ravencentric.github.io](https://ravencentric.github.io/projects).\n";
let ctx = create_context(content);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Should not flag 'github' when bare-domain text is the hostname of its destination URL: {result:?}"
);
}
#[test]
fn test_bare_domain_link_text_full_path_not_flagged() {
let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
let content = "See [ravencentric.github.io/blog](https://ravencentric.github.io/blog).\n";
let ctx = create_context(content);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Should not flag 'github' when link text is the full URL path without scheme: {result:?}"
);
}
#[test]
fn test_github_product_name_in_link_text_still_flagged() {
let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
let content = "Hosted on [github pages](https://pages.github.com).\n";
let ctx = create_context(content);
let result = rule.check(&ctx).unwrap();
assert!(
!result.is_empty(),
"Should still flag 'github' in descriptive link text that does not match the destination URL"
);
}
#[test]
fn test_protocol_relative_bare_domain_link_text_not_flagged() {
let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
let content = "See [github.io](//github.io).\n";
let ctx = create_context(content);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Should not flag 'github' in bare-domain text matching a protocol-relative destination: {result:?}"
);
}
#[test]
fn test_dotted_wikilink_target_still_flagged() {
let rule = MD044ProperNames::new(vec!["Node.js".to_string()], false);
let content = "See [[node.js]] for details.\n";
let ctx = create_context(content);
let result = rule.check(&ctx).unwrap();
assert!(
!result.is_empty(),
"Should flag 'node.js' in a dotted WikiLink target: {result:?}"
);
}
#[test]
fn test_bare_domain_link_text_case_insensitive_url() {
let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
let content = "See [github.io](HTTPS://github.io).\n";
let ctx = create_context(content);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Should not flag bare-domain text when destination URL has an uppercase scheme: {result:?}"
);
}
#[test]
fn test_frontmatter_value_span_strips_trailing_comment() {
let line = "link: docs/guide/myapp # canonical path";
let (s, e) = frontmatter_values::value_span(line).unwrap();
assert_eq!(&line[s..e], "docs/guide/myapp");
}
#[test]
fn test_frontmatter_value_span_quoted_keeps_hash_and_spaces() {
let line = "link: 'docs/My App/a#b'";
let (s, e) = frontmatter_values::value_span(line).unwrap();
assert_eq!(&line[s..e], "docs/My App/a#b");
}
#[test]
fn test_frontmatter_value_span_plain_value() {
let line = "title: Heading for myapp";
let (s, e) = frontmatter_values::value_span(line).unwrap();
assert_eq!(&line[s..e], "Heading for myapp");
}
#[test]
fn test_frontmatter_value_span_none_for_key_only() {
assert!(frontmatter_values::value_span("seo:").is_none());
assert!(frontmatter_values::value_span("---").is_none());
}
#[test]
fn test_frontmatter_value_span_quoted_strips_trailing_comment() {
let line = "link: 'docs/guide' # canonical path";
let (s, e) = frontmatter_values::value_span(line).unwrap();
assert_eq!(&line[s..e], "docs/guide");
}
#[test]
fn test_frontmatter_value_span_empty_quoted_value_is_none() {
assert!(frontmatter_values::value_span("key: ''").is_none());
}
#[test]
fn test_frontmatter_value_span_unterminated_quote_strips_leading_quote() {
let line = "link: 'docs/a";
let (s, e) = frontmatter_values::value_span(line).unwrap();
assert_eq!(&line[s..e], "docs/a");
}
fn at(line: &str, needle: &str) -> usize {
line.find(needle).expect("needle present")
}
#[test]
fn test_path_like_exempts_single_token_frontmatter_paths() {
for line in [
"link: this/is/a/link/to/myapp.md",
"link: docs/myapp.md",
"link: /abs/path/myapp.md",
"link: ./myapp.md",
"link: ../shared/myapp.md",
] {
let span = frontmatter_values::value_span(line).unwrap();
let pos = at(line, "myapp");
assert!(
MD044ProperNames::is_in_path_like_token(line, pos, span),
"should treat as a path: {line}"
);
}
}
#[test]
fn test_path_like_does_not_exempt_slash_conjunction_prose() {
let line = "description: We support github/gitlab/bitbucket imports.";
let span = frontmatter_values::value_span(line).unwrap();
assert!(
!MD044ProperNames::is_in_path_like_token(line, at(line, "github"), span),
"slash-separated prose is not a path"
);
let line = "description: The javascript/typescript ecosystem is large.";
let span = frontmatter_values::value_span(line).unwrap();
assert!(!MD044ProperNames::is_in_path_like_token(
line,
at(line, "javascript"),
span
));
}
#[test]
fn test_path_like_requires_a_slash_so_dotted_names_survive() {
let line = "title: Use nodejs and myapp.md today.";
let span = frontmatter_values::value_span(line).unwrap();
assert!(!MD044ProperNames::is_in_path_like_token(line, at(line, "myapp"), span));
}
#[test]
fn test_path_like_no_slash_frontmatter_value_still_flagged() {
let line = "slug: myapp-guide";
let span = frontmatter_values::value_span(line).unwrap();
assert!(!MD044ProperNames::is_in_path_like_token(line, at(line, "myapp"), span));
}
#[test]
fn test_path_like_returns_false_outside_value_span() {
let line = "myapp: docs/guide/myapp";
let span = frontmatter_values::value_span(line).unwrap();
let key_pos = 0;
assert!(!MD044ProperNames::is_in_path_like_token(line, key_pos, span));
}
#[test]
fn test_path_like_three_segments_only_as_sole_frontmatter_value() {
let line = "link: docs/guide/myapp";
let span = frontmatter_values::value_span(line).unwrap();
assert!(MD044ProperNames::is_in_path_like_token(line, at(line, "myapp"), span));
let line = "description: We support github/gitlab/bitbucket now";
let span = frontmatter_values::value_span(line).unwrap();
assert!(
!MD044ProperNames::is_in_path_like_token(line, at(line, "github"), span),
"multi-token value gets body treatment"
);
}
#[test]
fn test_path_like_quoted_value_with_spaces() {
let line = "link: 'docs/My App/myapp.md'";
let span = frontmatter_values::value_span(line).unwrap();
assert!(MD044ProperNames::is_in_path_like_token(line, at(line, "myapp"), span));
}
#[test]
fn test_path_like_quoted_value_with_spaces_no_extension_not_exempt() {
let line = "link: 'docs/My App/myapp'";
let span = frontmatter_values::value_span(line).unwrap();
assert!(!MD044ProperNames::is_in_path_like_token(line, at(line, "myapp"), span));
}
#[test]
fn test_path_like_trailing_comment_is_still_sole_value() {
let line = "link: docs/guide/myapp # canonical path";
let span = frontmatter_values::value_span(line).unwrap();
assert!(MD044ProperNames::is_in_path_like_token(line, at(line, "myapp"), span));
}
#[test]
fn test_path_like_trailing_punctuation_trimmed() {
let line = "link: docs/myapp.md, then leave.";
let span = frontmatter_values::value_span(line).unwrap();
assert!(MD044ProperNames::is_in_path_like_token(line, at(line, "myapp"), span));
}
#[test]
fn test_trim_token_bounds_reaches_fixpoint_after_punctuation_exposes_wrapper() {
let line = r#"See "docs/myapp.md", then leave."#;
let raw_start = at(line, "\"docs");
let raw_end = raw_start + r#""docs/myapp.md","#.len();
assert_eq!(&line[raw_start..raw_end], r#""docs/myapp.md","#);
let (start, end) = frontmatter_values::trim_token_bounds(line, raw_start, raw_end);
assert_eq!(&line[start..end], "docs/myapp.md");
}
#[test]
fn test_trim_token_bounds_reaches_fixpoint_with_multiple_trailing_wrappers() {
let line = r#"("docs/myapp.md")."#;
let (start, end) = frontmatter_values::trim_token_bounds(line, 0, line.len());
assert_eq!(&line[start..end], "docs/myapp.md");
}
#[test]
fn test_frontmatter_link_path_not_flagged() {
let content = "---\ntitle: Heading for MyApp\nlink: 'this/is/a/link/to/myapp.md'\n---\n\nBody.\n";
let rule = MD044ProperNames::new(vec!["MyApp".to_string()], false);
let ctx = create_context(content);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"path in a frontmatter value must not be flagged: {result:?}"
);
}
#[test]
fn test_fix_does_not_corrupt_frontmatter_link_path() {
let content = "---\nlink: 'this/is/a/link/to/myapp.md'\n---\n\nBody.\n";
let rule = MD044ProperNames::new(vec!["MyApp".to_string()], false);
let ctx = create_context(content);
assert_eq!(rule.fix(&ctx).unwrap(), content, "fix must not rewrite a path");
}
#[test]
fn test_body_prose_parenthesized_disambiguator_is_case_corrected() {
let content = "See docs/myapp(1).md here.\n";
let rule = MD044ProperNames::new(vec!["MyApp".to_string()], false);
let ctx = create_context(content);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1, "body occurrence is flagged: {result:?}");
assert_eq!(rule.fix(&ctx).unwrap(), "See docs/MyApp(1).md here.\n");
}
#[test]
fn test_body_prose_bracketed_dynamic_segment_is_case_corrected() {
let content = "See docs/[myapp].md here.\n";
let rule = MD044ProperNames::new(vec!["MyApp".to_string()], false);
let ctx = create_context(content);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1, "body occurrence is flagged: {result:?}");
assert_eq!(rule.fix(&ctx).unwrap(), "See docs/[MyApp].md here.\n");
}
#[test]
fn test_body_prose_nextjs_catch_all_segment_is_case_corrected() {
let content = "pages/[[...myapp]].tsx are catch-all routes.\n";
let rule = MD044ProperNames::new(vec!["MyApp".to_string()], false);
let ctx = create_context(content);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1, "body occurrence is flagged: {result:?}");
assert_eq!(
rule.fix(&ctx).unwrap(),
"pages/[[...MyApp]].tsx are catch-all routes.\n"
);
}
#[test]
fn test_two_adjacent_whitespace_free_links_both_flagged() {
let content = "[myapp](https://a.com)[github](https://b.com)\n";
let rule = MD044ProperNames::new(vec!["MyApp".to_string(), "GitHub".to_string()], false);
let ctx = create_context(content);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 2, "both link texts must be flagged: {result:?}");
assert!(result.iter().any(|w| w.message.contains("'myapp'")));
assert!(result.iter().any(|w| w.message.contains("'github'")));
}
#[test]
fn test_fix_does_not_corrupt_frontmatter_path_with_route_group_named_after_proper_name() {
let content = "---\nlink: src/(myapp)/page.tsx\n---\n\nBody.\n";
let rule = MD044ProperNames::new(vec!["MyApp".to_string()], false);
let ctx = create_context(content);
assert_eq!(
rule.fix(&ctx).unwrap(),
content,
"fix must not rewrite a frontmatter path whose route-group directory name is the proper name"
);
}
#[test]
fn test_quoted_frontmatter_value_slash_conjunction_prose_still_flagged() {
let content = "---\ndescription: \"We support github/gitlab/bitbucket now\"\n---\n\nBody.\n";
let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
let ctx = create_context(content);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result.len(),
1,
"quoted prose value must still flag 'github': {result:?}"
);
}
#[test]
fn test_quoted_frontmatter_value_single_slash_word_with_unrelated_dot_still_flagged() {
let content = "---\ndescription: \"We use myapp/gitlab and version 1.0 e.g. weekly\"\n---\n\nBody.\n";
let rule = MD044ProperNames::new(vec!["MyApp".to_string()], false);
let ctx = create_context(content);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result.len(),
1,
"quoted prose value must still flag 'myapp': {result:?}"
);
}
#[test]
fn test_quoted_toml_frontmatter_value_slash_conjunction_prose_still_flagged() {
let content = "+++\ndescription = \"We support github/gitlab/bitbucket now\"\n+++\n\nBody.\n";
let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
let ctx = create_context(content);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result.len(),
1,
"TOML quoted prose value must still flag 'github': {result:?}"
);
}
#[test]
fn test_path_like_collapsed_multiword_no_extension_not_exempt() {
for line in [
r#"description: "myapp/gitlab github/bitbucket""#,
r#"description: "and/or this/that myapp/gitlab""#,
r#"description: "he/him she/her myapp/gitlab""#,
] {
let span = frontmatter_values::value_span(line).unwrap();
for needle in ["myapp", "gitlab"] {
if let Some(byte_pos) = line.find(needle) {
assert!(
!MD044ProperNames::is_in_path_like_token(line, byte_pos, span),
"collapsed multi-word value must not exempt '{needle}': {line}"
);
}
}
}
}
#[test]
fn test_path_like_collapsed_multiword_no_extension_not_exempt_toml() {
let line = r#"description = "myapp/gitlab github/bitbucket""#;
let span = frontmatter_values::value_span(line).unwrap();
for needle in ["myapp", "gitlab", "github", "bitbucket"] {
let byte_pos = at(line, needle);
assert!(
!MD044ProperNames::is_in_path_like_token(line, byte_pos, span),
"collapsed multi-word TOML value must not exempt '{needle}'"
);
}
}
#[test]
fn test_frontmatter_collapsed_multiword_names_all_flagged_yaml() {
let content = "---\ndescription: \"myapp/gitlab github/bitbucket\"\n---\n\nBody.\n";
let rule = MD044ProperNames::new(
vec![
"MyApp".to_string(),
"GitLab".to_string(),
"GitHub".to_string(),
"Bitbucket".to_string(),
],
false,
);
let ctx = create_context(content);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result.len(),
4,
"all four names in the collapsed multi-word value must be flagged: {result:?}"
);
}
#[test]
fn test_frontmatter_collapsed_multiword_names_all_flagged_toml() {
let content = "+++\ndescription = \"myapp/gitlab github/bitbucket\"\n+++\n\nBody.\n";
let rule = MD044ProperNames::new(
vec![
"MyApp".to_string(),
"GitLab".to_string(),
"GitHub".to_string(),
"Bitbucket".to_string(),
],
false,
);
let ctx = create_context(content);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result.len(),
4,
"all four names in the collapsed multi-word TOML value must be flagged: {result:?}"
);
}
#[test]
fn test_frontmatter_collapsed_multiword_conjunction_pairs_flagged() {
let content = "---\ndescription: \"and/or this/that myapp/gitlab\"\n---\n\nBody.\n";
let rule = MD044ProperNames::new(vec!["MyApp".to_string(), "GitLab".to_string()], false);
let ctx = create_context(content);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result.len(),
2,
"myapp and gitlab must both be flagged despite the surrounding slash pairs: {result:?}"
);
}
#[test]
fn test_frontmatter_collapsed_multiword_pronoun_pairs_flagged() {
let content = "---\ndescription: \"he/him she/her myapp/gitlab\"\n---\n\nBody.\n";
let rule = MD044ProperNames::new(vec!["MyApp".to_string(), "GitLab".to_string()], false);
let ctx = create_context(content);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result.len(),
2,
"myapp and gitlab must both be flagged despite the surrounding slash pairs: {result:?}"
);
}
#[test]
fn test_body_prose_path_is_flagged_frontmatter_only_scope() {
let content = "See docs/myapp.md for details about myapp.\n";
let rule = MD044ProperNames::new(vec!["MyApp".to_string()], false);
let ctx = create_context(content);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result.len(),
2,
"both the path occurrence and the prose occurrence are flagged in body text: {result:?}"
);
assert_eq!(rule.fix(&ctx).unwrap(), "See docs/MyApp.md for details about MyApp.\n");
}
#[test]
fn test_slash_conjunction_prose_still_flagged() {
let content = "We support github/gitlab imports.\n";
let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
let ctx = create_context(content);
assert_eq!(rule.check(&ctx).unwrap().len(), 1);
}
}