use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
use crate::utils::range_utils::LineIndex;
use regex::Regex;
use std::collections::HashSet;
use std::ops::Range;
use std::sync::LazyLock;
mod md063_config;
pub use md063_config::{HeadingCapStyle, MD063Config};
static INLINE_CODE_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"`+[^`]+`+").unwrap());
static LINK_REGEX: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"\[([^\]]*)\]\([^)]*\)|\[([^\]]*)\]\[[^\]]*\]").unwrap());
static HTML_TAG_REGEX: LazyLock<Regex> = LazyLock::new(|| {
let tags = "kbd|abbr|code|span|sub|sup|mark|cite|dfn|var|samp|small|strong|em|b|i|u|s|q|br|wbr";
let pattern = format!(r"<({tags})(?:\s[^>]*)?>.*?</({tags})>|<({tags})(?:\s[^>]*)?\s*/?>");
Regex::new(&pattern).unwrap()
});
static CUSTOM_ID_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\s*\{#[^}]+\}\s*$").unwrap());
#[derive(Debug, Clone)]
enum HeadingSegment {
Text(String),
Code(String),
Link {
full: String,
text_start: usize,
text_end: usize,
},
Html(String),
}
#[derive(Clone)]
pub struct MD063HeadingCapitalization {
config: MD063Config,
lowercase_set: HashSet<String>,
proper_names: Vec<String>,
}
impl Default for MD063HeadingCapitalization {
fn default() -> Self {
Self::new()
}
}
impl MD063HeadingCapitalization {
pub fn new() -> Self {
let config = MD063Config::default();
let lowercase_set = config.lowercase_words.iter().cloned().collect();
Self {
config,
lowercase_set,
proper_names: Vec::new(),
}
}
pub fn from_config_struct(config: MD063Config) -> Self {
let lowercase_set = config.lowercase_words.iter().cloned().collect();
Self {
config,
lowercase_set,
proper_names: Vec::new(),
}
}
fn match_case_insensitive_at(text: &str, start: usize, pattern_lower: &str) -> Option<usize> {
if start > text.len() || !text.is_char_boundary(start) || pattern_lower.is_empty() {
return None;
}
let mut matched_bytes = 0;
for (offset, ch) in text[start..].char_indices() {
if matched_bytes >= pattern_lower.len() {
break;
}
let lowered: String = ch.to_lowercase().collect();
if !pattern_lower[matched_bytes..].starts_with(&lowered) {
return None;
}
matched_bytes += lowered.len();
if matched_bytes == pattern_lower.len() {
return Some(start + offset + ch.len_utf8());
}
}
None
}
fn find_case_insensitive_match(text: &str, pattern_lower: &str, search_start: usize) -> Option<(usize, usize)> {
if pattern_lower.is_empty() || search_start >= text.len() || !text.is_char_boundary(search_start) {
return None;
}
for (offset, _) in text[search_start..].char_indices() {
let start = search_start + offset;
if let Some(end) = Self::match_case_insensitive_at(text, start, pattern_lower) {
return Some((start, end));
}
}
None
}
fn proper_name_canonical_forms(&self, text: &str) -> std::collections::HashMap<usize, &str> {
let mut map = std::collections::HashMap::new();
for name in &self.proper_names {
if name.is_empty() {
continue;
}
let name_lower = name.to_lowercase();
let canonical_words: Vec<&str> = name.split_whitespace().collect();
if canonical_words.is_empty() {
continue;
}
let mut search_start = 0;
while search_start < text.len() {
let Some((abs_pos, end_pos)) = Self::find_case_insensitive_match(text, &name_lower, search_start)
else {
break;
};
let before_ok = abs_pos == 0 || !text[..abs_pos].chars().last().is_some_and(|c| c.is_alphanumeric());
let after_ok =
end_pos >= text.len() || !text[end_pos..].chars().next().is_some_and(|c| c.is_alphanumeric());
if before_ok && after_ok {
let text_slice = &text[abs_pos..end_pos];
let mut word_idx = 0;
let mut slice_offset = 0;
for text_word in text_slice.split_whitespace() {
if let Some(w_rel) = text_slice[slice_offset..].find(text_word) {
let word_abs = abs_pos + slice_offset + w_rel;
if let Some(&canonical_word) = canonical_words.get(word_idx) {
map.insert(word_abs, canonical_word);
}
slice_offset += w_rel + text_word.len();
word_idx += 1;
}
}
}
search_start = abs_pos + text[abs_pos..].chars().next().map_or(1, |c| c.len_utf8());
}
}
map
}
fn has_internal_capitals(&self, word: &str) -> bool {
let chars: Vec<char> = word.chars().collect();
if chars.len() < 2 {
return false;
}
let first = chars[0];
let rest = &chars[1..];
let has_upper_in_rest = rest.iter().any(|c| c.is_uppercase());
let has_lower_in_rest = rest.iter().any(|c| c.is_lowercase());
if has_upper_in_rest && has_lower_in_rest {
return true;
}
if first.is_lowercase() && has_upper_in_rest {
return true;
}
false
}
fn is_all_caps_acronym(&self, word: &str) -> bool {
if word.len() < 2 {
return false;
}
let mut consecutive_upper = 0;
let mut max_consecutive = 0;
for c in word.chars() {
if c.is_uppercase() {
consecutive_upper += 1;
max_consecutive = max_consecutive.max(consecutive_upper);
} else if c.is_lowercase() {
return false;
} else {
consecutive_upper = 0;
}
}
max_consecutive >= 2
}
fn should_preserve_word(&self, word: &str) -> bool {
if self.config.ignore_words.iter().any(|w| w == word) {
return true;
}
if self.config.preserve_cased_words && self.has_internal_capitals(word) {
return true;
}
if self.config.preserve_cased_words && self.is_all_caps_acronym(word) {
return true;
}
if self.is_caret_notation(word) {
return true;
}
false
}
fn is_caret_notation(&self, word: &str) -> bool {
let chars: Vec<char> = word.chars().collect();
if chars.len() >= 2 && chars[0] == '^' {
let second = chars[1];
if second.is_ascii_uppercase() || "@[\\]^_".contains(second) {
return true;
}
}
false
}
fn is_lowercase_word(&self, word: &str) -> bool {
self.lowercase_set.contains(&word.to_lowercase())
}
fn title_case_word(&self, word: &str, is_first: bool, is_last: bool) -> String {
if word.is_empty() {
return word.to_string();
}
if self.should_preserve_word(word) {
return word.to_string();
}
if is_first || is_last {
return self.capitalize_first(word);
}
if self.is_lowercase_word(word) {
return Self::lowercase_preserving_composition(word);
}
self.capitalize_first(word)
}
fn apply_canonical_form_to_word(word: &str, canonical: &str) -> String {
let canonical_lower = canonical.to_lowercase();
if canonical_lower.is_empty() {
return canonical.to_string();
}
if let Some(end_pos) = Self::match_case_insensitive_at(word, 0, &canonical_lower) {
let mut out = String::with_capacity(canonical.len() + word.len().saturating_sub(end_pos));
out.push_str(canonical);
out.push_str(&word[end_pos..]);
out
} else {
canonical.to_string()
}
}
fn capitalize_first(&self, word: &str) -> String {
if word.is_empty() {
return String::new();
}
let first_alpha_pos = word.find(|c: char| c.is_alphabetic());
let Some(pos) = first_alpha_pos else {
return word.to_string();
};
let prefix = &word[..pos];
let mut chars = word[pos..].chars();
let first = chars.next().unwrap();
let first_upper = Self::uppercase_preserving_composition(&first.to_string());
let rest: String = chars.collect();
let rest_lower = Self::lowercase_preserving_composition(&rest);
format!("{prefix}{first_upper}{rest_lower}")
}
fn lowercase_preserving_composition(s: &str) -> String {
let mut result = String::with_capacity(s.len());
for c in s.chars() {
let lower: String = c.to_lowercase().collect();
if lower.chars().count() == 1 {
result.push_str(&lower);
} else {
result.push(c);
}
}
result
}
fn uppercase_preserving_composition(s: &str) -> String {
let mut result = String::with_capacity(s.len());
for c in s.chars() {
let upper: String = c.to_uppercase().collect();
if upper.chars().count() == 1 {
result.push_str(&upper);
} else {
result.push(c);
}
}
result
}
fn apply_title_case(&self, text: &str) -> String {
let canonical_forms = self.proper_name_canonical_forms(text);
let original_words: Vec<&str> = text.split_whitespace().collect();
let total_words = original_words.len();
let mut word_positions: Vec<usize> = Vec::with_capacity(original_words.len());
let mut pos = 0;
for word in &original_words {
if let Some(rel) = text[pos..].find(word) {
word_positions.push(pos + rel);
pos = pos + rel + word.len();
} else {
word_positions.push(usize::MAX);
}
}
let result_words: Vec<String> = original_words
.iter()
.enumerate()
.map(|(i, word)| {
let is_first = i == 0;
let is_last = i == total_words - 1;
if let Some(&canonical) = word_positions.get(i).and_then(|&p| canonical_forms.get(&p)) {
return Self::apply_canonical_form_to_word(word, canonical);
}
if self.should_preserve_word(word) {
return (*word).to_string();
}
if word.contains('-') {
return self.handle_hyphenated_word(word, is_first, is_last);
}
self.title_case_word(word, is_first, is_last)
})
.collect();
result_words.join(" ")
}
fn handle_hyphenated_word(&self, word: &str, is_first: bool, is_last: bool) -> String {
let parts: Vec<&str> = word.split('-').collect();
let total_parts = parts.len();
let result_parts: Vec<String> = parts
.iter()
.enumerate()
.map(|(i, part)| {
let part_is_first = is_first && i == 0;
let part_is_last = is_last && i == total_parts - 1;
self.title_case_word(part, part_is_first, part_is_last)
})
.collect();
result_parts.join("-")
}
fn apply_sentence_case(&self, text: &str) -> String {
if text.is_empty() {
return text.to_string();
}
let canonical_forms = self.proper_name_canonical_forms(text);
let mut result = String::new();
let mut current_pos = 0;
let mut is_first_word = true;
for word in text.split_whitespace() {
if let Some(pos) = text[current_pos..].find(word) {
let abs_pos = current_pos + pos;
result.push_str(&text[current_pos..abs_pos]);
if let Some(&canonical) = canonical_forms.get(&abs_pos) {
result.push_str(&Self::apply_canonical_form_to_word(word, canonical));
is_first_word = false;
} else if is_first_word {
if self.should_preserve_word(word) {
result.push_str(word);
} else {
let mut chars = word.chars();
if let Some(first) = chars.next() {
result.push_str(&Self::uppercase_preserving_composition(&first.to_string()));
let rest: String = chars.collect();
result.push_str(&Self::lowercase_preserving_composition(&rest));
}
}
is_first_word = false;
} else {
if self.should_preserve_word(word) {
result.push_str(word);
} else {
result.push_str(&Self::lowercase_preserving_composition(word));
}
}
current_pos = abs_pos + word.len();
}
}
if current_pos < text.len() {
result.push_str(&text[current_pos..]);
}
result
}
fn apply_all_caps(&self, text: &str) -> String {
if text.is_empty() {
return text.to_string();
}
let canonical_forms = self.proper_name_canonical_forms(text);
let mut result = String::new();
let mut current_pos = 0;
for word in text.split_whitespace() {
if let Some(pos) = text[current_pos..].find(word) {
let abs_pos = current_pos + pos;
result.push_str(&text[current_pos..abs_pos]);
if let Some(&canonical) = canonical_forms.get(&abs_pos) {
result.push_str(&Self::apply_canonical_form_to_word(word, canonical));
} else if self.should_preserve_word(word) {
result.push_str(word);
} else {
result.push_str(&Self::uppercase_preserving_composition(word));
}
current_pos = abs_pos + word.len();
}
}
if current_pos < text.len() {
result.push_str(&text[current_pos..]);
}
result
}
fn parse_segments(&self, text: &str) -> Vec<HeadingSegment> {
let mut segments = Vec::new();
let mut last_end = 0;
let mut special_regions: Vec<(usize, usize, HeadingSegment)> = Vec::new();
for mat in INLINE_CODE_REGEX.find_iter(text) {
special_regions.push((mat.start(), mat.end(), HeadingSegment::Code(mat.as_str().to_string())));
}
for caps in LINK_REGEX.captures_iter(text) {
let full_match = caps.get(0).unwrap();
let text_match = caps.get(1).or_else(|| caps.get(2));
if let Some(text_m) = text_match {
special_regions.push((
full_match.start(),
full_match.end(),
HeadingSegment::Link {
full: full_match.as_str().to_string(),
text_start: text_m.start() - full_match.start(),
text_end: text_m.end() - full_match.start(),
},
));
}
}
for mat in HTML_TAG_REGEX.find_iter(text) {
special_regions.push((mat.start(), mat.end(), HeadingSegment::Html(mat.as_str().to_string())));
}
special_regions.sort_by_key(|(start, _, _)| *start);
let mut filtered_regions: Vec<(usize, usize, HeadingSegment)> = Vec::new();
for region in special_regions {
let overlaps = filtered_regions.iter().any(|(s, e, _)| region.0 < *e && region.1 > *s);
if !overlaps {
filtered_regions.push(region);
}
}
for (start, end, segment) in filtered_regions {
if start > last_end {
let text_segment = &text[last_end..start];
if !text_segment.is_empty() {
segments.push(HeadingSegment::Text(text_segment.to_string()));
}
}
segments.push(segment);
last_end = end;
}
if last_end < text.len() {
let remaining = &text[last_end..];
if !remaining.is_empty() {
segments.push(HeadingSegment::Text(remaining.to_string()));
}
}
if segments.is_empty() && !text.is_empty() {
segments.push(HeadingSegment::Text(text.to_string()));
}
segments
}
fn apply_capitalization(&self, text: &str) -> String {
let (main_text, custom_id) = if let Some(mat) = CUSTOM_ID_REGEX.find(text) {
(&text[..mat.start()], Some(mat.as_str()))
} else {
(text, None)
};
let segments = self.parse_segments(main_text);
let text_segments: Vec<usize> = segments
.iter()
.enumerate()
.filter_map(|(i, s)| matches!(s, HeadingSegment::Text(_)).then_some(i))
.collect();
let first_segment_is_text = segments
.first()
.map(|s| matches!(s, HeadingSegment::Text(_)))
.unwrap_or(false);
let last_segment_is_text = segments
.last()
.map(|s| matches!(s, HeadingSegment::Text(_)))
.unwrap_or(false);
let mut result_parts: Vec<String> = Vec::new();
for (i, segment) in segments.iter().enumerate() {
match segment {
HeadingSegment::Text(t) => {
let is_first_text = text_segments.first() == Some(&i);
let is_last_text = text_segments.last() == Some(&i) && last_segment_is_text;
let capitalized = match self.config.style {
HeadingCapStyle::TitleCase => self.apply_title_case_segment(t, is_first_text, is_last_text),
HeadingCapStyle::SentenceCase => {
if is_first_text && first_segment_is_text {
self.apply_sentence_case(t)
} else {
self.apply_sentence_case_non_first(t)
}
}
HeadingCapStyle::AllCaps => self.apply_all_caps(t),
};
result_parts.push(capitalized);
}
HeadingSegment::Code(c) => {
result_parts.push(c.clone());
}
HeadingSegment::Link {
full,
text_start,
text_end,
} => {
let link_text = &full[*text_start..*text_end];
let capitalized_text = match self.config.style {
HeadingCapStyle::TitleCase => self.apply_title_case(link_text),
HeadingCapStyle::SentenceCase => self.apply_sentence_case_non_first(link_text),
HeadingCapStyle::AllCaps => self.apply_all_caps(link_text),
};
let mut new_link = String::new();
new_link.push_str(&full[..*text_start]);
new_link.push_str(&capitalized_text);
new_link.push_str(&full[*text_end..]);
result_parts.push(new_link);
}
HeadingSegment::Html(h) => {
result_parts.push(h.clone());
}
}
}
let mut result = result_parts.join("");
if let Some(id) = custom_id {
result.push_str(id);
}
result
}
fn apply_title_case_segment(&self, text: &str, is_first_segment: bool, is_last_segment: bool) -> String {
let canonical_forms = self.proper_name_canonical_forms(text);
let words: Vec<&str> = text.split_whitespace().collect();
let total_words = words.len();
if total_words == 0 {
return text.to_string();
}
let mut word_positions: Vec<usize> = Vec::with_capacity(words.len());
let mut pos = 0;
for word in &words {
if let Some(rel) = text[pos..].find(word) {
word_positions.push(pos + rel);
pos = pos + rel + word.len();
} else {
word_positions.push(usize::MAX);
}
}
let result_words: Vec<String> = words
.iter()
.enumerate()
.map(|(i, word)| {
let is_first = is_first_segment && i == 0;
let is_last = is_last_segment && i == total_words - 1;
if let Some(&canonical) = word_positions.get(i).and_then(|&p| canonical_forms.get(&p)) {
return Self::apply_canonical_form_to_word(word, canonical);
}
if word.contains('-') {
return self.handle_hyphenated_word(word, is_first, is_last);
}
self.title_case_word(word, is_first, is_last)
})
.collect();
let mut result = String::new();
let mut word_iter = result_words.iter();
let mut in_word = false;
for c in text.chars() {
if c.is_whitespace() {
if in_word {
in_word = false;
}
result.push(c);
} else if !in_word {
if let Some(word) = word_iter.next() {
result.push_str(word);
}
in_word = true;
}
}
result
}
fn apply_sentence_case_non_first(&self, text: &str) -> String {
if text.is_empty() {
return text.to_string();
}
let canonical_forms = self.proper_name_canonical_forms(text);
let mut result = String::new();
let mut current_pos = 0;
for word in text.split_whitespace() {
if let Some(pos) = text[current_pos..].find(word) {
let abs_pos = current_pos + pos;
result.push_str(&text[current_pos..abs_pos]);
if let Some(&canonical) = canonical_forms.get(&abs_pos) {
result.push_str(&Self::apply_canonical_form_to_word(word, canonical));
} else if self.should_preserve_word(word) {
result.push_str(word);
} else {
result.push_str(&Self::lowercase_preserving_composition(word));
}
current_pos = abs_pos + word.len();
}
}
if current_pos < text.len() {
result.push_str(&text[current_pos..]);
}
result
}
fn get_line_byte_range(&self, content: &str, line_num: usize, line_index: &LineIndex) -> Range<usize> {
let start_pos = line_index.get_line_start_byte(line_num).unwrap_or(content.len());
let line = content.lines().nth(line_num - 1).unwrap_or("");
Range {
start: start_pos,
end: start_pos + line.len(),
}
}
fn fix_atx_heading(&self, _line: &str, heading: &crate::lint_context::HeadingInfo) -> String {
let indent = " ".repeat(heading.marker_column);
let hashes = "#".repeat(heading.level as usize);
let fixed_text = self.apply_capitalization(&heading.raw_text);
let closing = &heading.closing_sequence;
if heading.has_closing_sequence {
format!("{indent}{hashes} {fixed_text} {closing}")
} else {
format!("{indent}{hashes} {fixed_text}")
}
}
fn fix_setext_heading(&self, line: &str, heading: &crate::lint_context::HeadingInfo) -> String {
let fixed_text = self.apply_capitalization(&heading.raw_text);
let leading_ws: String = line.chars().take_while(|c| c.is_whitespace()).collect();
format!("{leading_ws}{fixed_text}")
}
}
impl Rule for MD063HeadingCapitalization {
fn name(&self) -> &'static str {
"MD063"
}
fn description(&self) -> &'static str {
"Heading capitalization"
}
fn category(&self) -> RuleCategory {
RuleCategory::Heading
}
fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
!ctx.likely_has_headings() || !ctx.lines.iter().any(|line| line.heading.is_some())
}
fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
let content = ctx.content;
if content.is_empty() {
return Ok(Vec::new());
}
let mut warnings = Vec::new();
let line_index = &ctx.line_index;
for (line_num, line_info) in ctx.lines.iter().enumerate() {
if let Some(heading) = &line_info.heading {
if heading.level < self.config.min_level || heading.level > self.config.max_level {
continue;
}
if line_info.visual_indent >= 4 && matches!(heading.style, crate::lint_context::HeadingStyle::ATX) {
continue;
}
let original_text = &heading.raw_text;
let fixed_text = self.apply_capitalization(original_text);
if original_text != &fixed_text {
let line = line_info.content(ctx.content);
let style_name = match self.config.style {
HeadingCapStyle::TitleCase => "title case",
HeadingCapStyle::SentenceCase => "sentence case",
HeadingCapStyle::AllCaps => "ALL CAPS",
};
warnings.push(LintWarning {
rule_name: Some(self.name().to_string()),
line: line_num + 1,
column: heading.content_column + 1,
end_line: line_num + 1,
end_column: heading.content_column + 1 + original_text.len(),
message: format!("Heading should use {style_name}: '{original_text}' -> '{fixed_text}'"),
severity: Severity::Warning,
fix: Some(Fix {
range: self.get_line_byte_range(content, line_num + 1, line_index),
replacement: match heading.style {
crate::lint_context::HeadingStyle::ATX => self.fix_atx_heading(line, heading),
_ => self.fix_setext_heading(line, heading),
},
}),
});
}
}
}
Ok(warnings)
}
fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
let content = ctx.content;
if content.is_empty() {
return Ok(content.to_string());
}
let lines = ctx.raw_lines();
let mut fixed_lines: Vec<String> = lines.iter().map(|&s| s.to_string()).collect();
for (line_num, line_info) in ctx.lines.iter().enumerate() {
if ctx.is_rule_disabled(self.name(), line_num + 1) {
continue;
}
if let Some(heading) = &line_info.heading {
if heading.level < self.config.min_level || heading.level > self.config.max_level {
continue;
}
if line_info.visual_indent >= 4 && matches!(heading.style, crate::lint_context::HeadingStyle::ATX) {
continue;
}
let original_text = &heading.raw_text;
let fixed_text = self.apply_capitalization(original_text);
if original_text != &fixed_text {
let line = line_info.content(ctx.content);
fixed_lines[line_num] = match heading.style {
crate::lint_context::HeadingStyle::ATX => self.fix_atx_heading(line, heading),
_ => self.fix_setext_heading(line, heading),
};
}
}
}
let mut result = String::with_capacity(content.len());
for (i, line) in fixed_lines.iter().enumerate() {
result.push_str(line);
if i < fixed_lines.len() - 1 || content.ends_with('\n') {
result.push('\n');
}
}
Ok(result)
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
fn default_config_section(&self) -> Option<(String, toml::Value)> {
let json_value = serde_json::to_value(&self.config).ok()?;
Some((
self.name().to_string(),
crate::rule_config_serde::json_to_toml_value(&json_value)?,
))
}
fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
where
Self: Sized,
{
let rule_config = crate::rule_config_serde::load_rule_config::<MD063Config>(config);
let md044_config =
crate::rule_config_serde::load_rule_config::<crate::rules::md044_proper_names::MD044Config>(config);
let mut rule = Self::from_config_struct(rule_config);
rule.proper_names = md044_config.names;
Box::new(rule)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::lint_context::LintContext;
fn create_rule() -> MD063HeadingCapitalization {
let config = MD063Config {
enabled: true,
..Default::default()
};
MD063HeadingCapitalization::from_config_struct(config)
}
fn create_rule_with_style(style: HeadingCapStyle) -> MD063HeadingCapitalization {
let config = MD063Config {
enabled: true,
style,
..Default::default()
};
MD063HeadingCapitalization::from_config_struct(config)
}
#[test]
fn test_title_case_basic() {
let rule = create_rule();
let content = "# hello world\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1);
assert!(result[0].message.contains("Hello World"));
}
#[test]
fn test_title_case_lowercase_words() {
let rule = create_rule();
let content = "# the quick brown fox\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1);
assert!(result[0].message.contains("The Quick Brown Fox"));
}
#[test]
fn test_title_case_already_correct() {
let rule = create_rule();
let content = "# The Quick Brown Fox\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty(), "Already correct heading should not be flagged");
}
#[test]
fn test_title_case_hyphenated() {
let rule = create_rule();
let content = "# self-documenting code\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1);
assert!(result[0].message.contains("Self-Documenting Code"));
}
#[test]
fn test_sentence_case_basic() {
let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
let content = "# The Quick Brown Fox\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1);
assert!(result[0].message.contains("The quick brown fox"));
}
#[test]
fn test_sentence_case_already_correct() {
let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
let content = "# The quick brown fox\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty());
}
#[test]
fn test_all_caps_basic() {
let rule = create_rule_with_style(HeadingCapStyle::AllCaps);
let content = "# hello world\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1);
assert!(result[0].message.contains("HELLO WORLD"));
}
#[test]
fn test_preserve_ignore_words() {
let config = MD063Config {
enabled: true,
ignore_words: vec!["iPhone".to_string(), "macOS".to_string()],
..Default::default()
};
let rule = MD063HeadingCapitalization::from_config_struct(config);
let content = "# using iPhone on macOS\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1);
assert!(result[0].message.contains("iPhone"));
assert!(result[0].message.contains("macOS"));
}
#[test]
fn test_preserve_cased_words() {
let rule = create_rule();
let content = "# using GitHub actions\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1);
assert!(result[0].message.contains("GitHub"));
}
#[test]
fn test_inline_code_preserved() {
let rule = create_rule();
let content = "# using `const` in javascript\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1);
assert!(result[0].message.contains("`const`"));
assert!(result[0].message.contains("Javascript") || result[0].message.contains("JavaScript"));
}
#[test]
fn test_level_filter() {
let config = MD063Config {
enabled: true,
min_level: 2,
max_level: 4,
..Default::default()
};
let rule = MD063HeadingCapitalization::from_config_struct(config);
let content = "# h1 heading\n## h2 heading\n### h3 heading\n##### h5 heading\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 2);
assert_eq!(result[0].line, 2); assert_eq!(result[1].line, 3); }
#[test]
fn test_fix_atx_heading() {
let rule = create_rule();
let content = "# hello world\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, "# Hello World\n");
}
#[test]
fn test_fix_multiple_headings() {
let rule = create_rule();
let content = "# first heading\n\n## second heading\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, "# First Heading\n\n## Second Heading\n");
}
#[test]
fn test_setext_heading() {
let rule = create_rule();
let content = "hello world\n============\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1);
assert!(result[0].message.contains("Hello World"));
}
#[test]
fn test_custom_id_preserved() {
let rule = create_rule();
let content = "# getting started {#intro}\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1);
assert!(result[0].message.contains("{#intro}"));
}
#[test]
fn test_preserve_all_caps_acronyms() {
let rule = create_rule();
let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx("# using API in production\n")).unwrap();
assert_eq!(fixed, "# Using API in Production\n");
let fixed = rule.fix(&ctx("# API and GPU integration\n")).unwrap();
assert_eq!(fixed, "# API and GPU Integration\n");
let fixed = rule.fix(&ctx("# IO performance guide\n")).unwrap();
assert_eq!(fixed, "# IO Performance Guide\n");
let fixed = rule.fix(&ctx("# HTTP2 and MD5 hashing\n")).unwrap();
assert_eq!(fixed, "# HTTP2 and MD5 Hashing\n");
}
#[test]
fn test_preserve_acronyms_in_hyphenated_words() {
let rule = create_rule();
let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx("# API-driven architecture\n")).unwrap();
assert_eq!(fixed, "# API-Driven Architecture\n");
let fixed = rule.fix(&ctx("# GPU-accelerated CPU-intensive tasks\n")).unwrap();
assert_eq!(fixed, "# GPU-Accelerated CPU-Intensive Tasks\n");
}
#[test]
fn test_single_letters_not_treated_as_acronyms() {
let rule = create_rule();
let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx("# i am a heading\n")).unwrap();
assert_eq!(fixed, "# I Am a Heading\n");
}
#[test]
fn test_lowercase_terms_need_ignore_words() {
let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
let rule = create_rule();
let fixed = rule.fix(&ctx("# using npm packages\n")).unwrap();
assert_eq!(fixed, "# Using Npm Packages\n");
let config = MD063Config {
enabled: true,
ignore_words: vec!["npm".to_string()],
..Default::default()
};
let rule = MD063HeadingCapitalization::from_config_struct(config);
let fixed = rule.fix(&ctx("# using npm packages\n")).unwrap();
assert_eq!(fixed, "# Using npm Packages\n");
}
#[test]
fn test_acronyms_with_mixed_case_preserved() {
let rule = create_rule();
let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx("# using API with GitHub\n")).unwrap();
assert_eq!(fixed, "# Using API with GitHub\n");
}
#[test]
fn test_real_world_acronyms() {
let rule = create_rule();
let ctx = |c| LintContext::new(c, crate::config::MarkdownFlavor::Standard, None);
let content = "# FFI bindings for CPU optimization\n";
let fixed = rule.fix(&ctx(content)).unwrap();
assert_eq!(fixed, "# FFI Bindings for CPU Optimization\n");
let content = "# DOM manipulation and SSR rendering\n";
let fixed = rule.fix(&ctx(content)).unwrap();
assert_eq!(fixed, "# DOM Manipulation and SSR Rendering\n");
let content = "# CVE security and RNN models\n";
let fixed = rule.fix(&ctx(content)).unwrap();
assert_eq!(fixed, "# CVE Security and RNN Models\n");
}
#[test]
fn test_is_all_caps_acronym() {
let rule = create_rule();
assert!(rule.is_all_caps_acronym("API"));
assert!(rule.is_all_caps_acronym("IO"));
assert!(rule.is_all_caps_acronym("GPU"));
assert!(rule.is_all_caps_acronym("HTTP2"));
assert!(!rule.is_all_caps_acronym("A"));
assert!(!rule.is_all_caps_acronym("I"));
assert!(!rule.is_all_caps_acronym("Api"));
assert!(!rule.is_all_caps_acronym("npm"));
assert!(!rule.is_all_caps_acronym("iPhone"));
}
#[test]
fn test_sentence_case_ignore_words_first_word() {
let config = MD063Config {
enabled: true,
style: HeadingCapStyle::SentenceCase,
ignore_words: vec!["nvim".to_string()],
..Default::default()
};
let rule = MD063HeadingCapitalization::from_config_struct(config);
let content = "# nvim config\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"nvim in ignore-words should not be flagged. Got: {result:?}"
);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, "# nvim config\n");
}
#[test]
fn test_sentence_case_ignore_words_not_first() {
let config = MD063Config {
enabled: true,
style: HeadingCapStyle::SentenceCase,
ignore_words: vec!["nvim".to_string()],
..Default::default()
};
let rule = MD063HeadingCapitalization::from_config_struct(config);
let content = "# Using nvim editor\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"nvim in ignore-words should be preserved. Got: {result:?}"
);
}
#[test]
fn test_preserve_cased_words_ios() {
let config = MD063Config {
enabled: true,
style: HeadingCapStyle::SentenceCase,
preserve_cased_words: true,
..Default::default()
};
let rule = MD063HeadingCapitalization::from_config_struct(config);
let content = "## This is iOS\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"iOS should be preserved with preserve-cased-words. Got: {result:?}"
);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, "## This is iOS\n");
}
#[test]
fn test_preserve_cased_words_ios_title_case() {
let config = MD063Config {
enabled: true,
style: HeadingCapStyle::TitleCase,
preserve_cased_words: true,
..Default::default()
};
let rule = MD063HeadingCapitalization::from_config_struct(config);
let content = "# developing for iOS\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, "# Developing for iOS\n");
}
#[test]
fn test_has_internal_capitals_ios() {
let rule = create_rule();
assert!(
rule.has_internal_capitals("iOS"),
"iOS has mixed case (lowercase i, uppercase OS)"
);
assert!(rule.has_internal_capitals("iPhone"));
assert!(rule.has_internal_capitals("macOS"));
assert!(rule.has_internal_capitals("GitHub"));
assert!(rule.has_internal_capitals("JavaScript"));
assert!(rule.has_internal_capitals("eBay"));
assert!(!rule.has_internal_capitals("API"));
assert!(!rule.has_internal_capitals("GPU"));
assert!(!rule.has_internal_capitals("npm"));
assert!(!rule.has_internal_capitals("config"));
assert!(!rule.has_internal_capitals("The"));
assert!(!rule.has_internal_capitals("Hello"));
}
#[test]
fn test_lowercase_words_before_trailing_code() {
let config = MD063Config {
enabled: true,
style: HeadingCapStyle::TitleCase,
lowercase_words: vec![
"a".to_string(),
"an".to_string(),
"and".to_string(),
"at".to_string(),
"but".to_string(),
"by".to_string(),
"for".to_string(),
"from".to_string(),
"into".to_string(),
"nor".to_string(),
"on".to_string(),
"onto".to_string(),
"or".to_string(),
"the".to_string(),
"to".to_string(),
"upon".to_string(),
"via".to_string(),
"vs".to_string(),
"with".to_string(),
"without".to_string(),
],
preserve_cased_words: true,
..Default::default()
};
let rule = MD063HeadingCapitalization::from_config_struct(config);
let content = "## subtitle with a `app`\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(!result.is_empty(), "Should flag incorrect capitalization");
let fixed = rule.fix(&ctx).unwrap();
assert!(
fixed.contains("with a `app`"),
"Expected 'with a `app`' but got: {fixed:?}"
);
assert!(
!fixed.contains("with A `app`"),
"Should not capitalize 'a' to 'A'. Got: {fixed:?}"
);
assert!(
fixed.contains("Subtitle with a `app`"),
"Expected 'Subtitle with a `app`' but got: {fixed:?}"
);
}
#[test]
fn test_lowercase_words_preserved_before_trailing_code_variant() {
let config = MD063Config {
enabled: true,
style: HeadingCapStyle::TitleCase,
lowercase_words: vec!["a".to_string(), "the".to_string(), "with".to_string()],
..Default::default()
};
let rule = MD063HeadingCapitalization::from_config_struct(config);
let content = "## Title with the `code`\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert!(
fixed.contains("with the `code`"),
"Expected 'with the `code`' but got: {fixed:?}"
);
assert!(
!fixed.contains("with The `code`"),
"Should not capitalize 'the' to 'The'. Got: {fixed:?}"
);
}
#[test]
fn test_last_word_capitalized_when_no_trailing_code() {
let config = MD063Config {
enabled: true,
style: HeadingCapStyle::TitleCase,
lowercase_words: vec!["a".to_string(), "the".to_string()],
..Default::default()
};
let rule = MD063HeadingCapitalization::from_config_struct(config);
let content = "## title with a word\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert!(
fixed.contains("With a Word"),
"Expected 'With a Word' but got: {fixed:?}"
);
}
#[test]
fn test_multiple_lowercase_words_before_code() {
let config = MD063Config {
enabled: true,
style: HeadingCapStyle::TitleCase,
lowercase_words: vec![
"a".to_string(),
"the".to_string(),
"with".to_string(),
"for".to_string(),
],
..Default::default()
};
let rule = MD063HeadingCapitalization::from_config_struct(config);
let content = "## Guide for the `user`\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert!(
fixed.contains("for the `user`"),
"Expected 'for the `user`' but got: {fixed:?}"
);
assert!(
!fixed.contains("For The `user`"),
"Should not capitalize lowercase words before code. Got: {fixed:?}"
);
}
#[test]
fn test_code_in_middle_normal_rules_apply() {
let config = MD063Config {
enabled: true,
style: HeadingCapStyle::TitleCase,
lowercase_words: vec!["a".to_string(), "the".to_string(), "for".to_string()],
..Default::default()
};
let rule = MD063HeadingCapitalization::from_config_struct(config);
let content = "## Using `const` for the code\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert!(
fixed.contains("for the Code"),
"Expected 'for the Code' but got: {fixed:?}"
);
}
#[test]
fn test_link_at_end_same_as_code() {
let config = MD063Config {
enabled: true,
style: HeadingCapStyle::TitleCase,
lowercase_words: vec!["a".to_string(), "the".to_string(), "for".to_string()],
..Default::default()
};
let rule = MD063HeadingCapitalization::from_config_struct(config);
let content = "## Guide for the [link](./page.md)\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert!(
fixed.contains("for the [Link]"),
"Expected 'for the [Link]' but got: {fixed:?}"
);
assert!(
!fixed.contains("for The [Link]"),
"Should not capitalize 'the' before link. Got: {fixed:?}"
);
}
#[test]
fn test_multiple_code_segments() {
let config = MD063Config {
enabled: true,
style: HeadingCapStyle::TitleCase,
lowercase_words: vec!["a".to_string(), "the".to_string(), "with".to_string()],
..Default::default()
};
let rule = MD063HeadingCapitalization::from_config_struct(config);
let content = "## Using `const` with a `variable`\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert!(
fixed.contains("with a `variable`"),
"Expected 'with a `variable`' but got: {fixed:?}"
);
assert!(
!fixed.contains("with A `variable`"),
"Should not capitalize 'a' before trailing code. Got: {fixed:?}"
);
}
#[test]
fn test_code_and_link_combination() {
let config = MD063Config {
enabled: true,
style: HeadingCapStyle::TitleCase,
lowercase_words: vec!["a".to_string(), "the".to_string(), "for".to_string()],
..Default::default()
};
let rule = MD063HeadingCapitalization::from_config_struct(config);
let content = "## Guide for the `code` [link](./page.md)\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert!(
fixed.contains("for the `code`"),
"Expected 'for the `code`' but got: {fixed:?}"
);
}
#[test]
fn test_text_after_code_capitalizes_last() {
let config = MD063Config {
enabled: true,
style: HeadingCapStyle::TitleCase,
lowercase_words: vec!["a".to_string(), "the".to_string(), "for".to_string()],
..Default::default()
};
let rule = MD063HeadingCapitalization::from_config_struct(config);
let content = "## Using `const` for the code\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert!(
fixed.contains("for the Code"),
"Expected 'for the Code' but got: {fixed:?}"
);
}
#[test]
fn test_preserve_cased_words_with_trailing_code() {
let config = MD063Config {
enabled: true,
style: HeadingCapStyle::TitleCase,
lowercase_words: vec!["a".to_string(), "the".to_string(), "for".to_string()],
preserve_cased_words: true,
..Default::default()
};
let rule = MD063HeadingCapitalization::from_config_struct(config);
let content = "## Guide for iOS `app`\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert!(
fixed.contains("for iOS `app`"),
"Expected 'for iOS `app`' but got: {fixed:?}"
);
assert!(
!fixed.contains("For iOS `app`"),
"Should not capitalize 'for' before trailing code. Got: {fixed:?}"
);
}
#[test]
fn test_ignore_words_with_trailing_code() {
let config = MD063Config {
enabled: true,
style: HeadingCapStyle::TitleCase,
lowercase_words: vec!["a".to_string(), "the".to_string(), "with".to_string()],
ignore_words: vec!["npm".to_string()],
..Default::default()
};
let rule = MD063HeadingCapitalization::from_config_struct(config);
let content = "## Using npm with a `script`\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert!(
fixed.contains("npm with a `script`"),
"Expected 'npm with a `script`' but got: {fixed:?}"
);
assert!(
!fixed.contains("with A `script`"),
"Should not capitalize 'a' before trailing code. Got: {fixed:?}"
);
}
#[test]
fn test_empty_text_segment_edge_case() {
let config = MD063Config {
enabled: true,
style: HeadingCapStyle::TitleCase,
lowercase_words: vec!["a".to_string(), "with".to_string()],
..Default::default()
};
let rule = MD063HeadingCapitalization::from_config_struct(config);
let content = "## `start` with a `end`\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert!(fixed.contains("a `end`"), "Expected 'a `end`' but got: {fixed:?}");
assert!(
!fixed.contains("A `end`"),
"Should not capitalize 'a' before trailing code. Got: {fixed:?}"
);
}
#[test]
fn test_sentence_case_with_trailing_code() {
let config = MD063Config {
enabled: true,
style: HeadingCapStyle::SentenceCase,
lowercase_words: vec!["a".to_string(), "the".to_string()],
..Default::default()
};
let rule = MD063HeadingCapitalization::from_config_struct(config);
let content = "## guide for the `user`\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert!(
fixed.contains("Guide for the `user`"),
"Expected 'Guide for the `user`' but got: {fixed:?}"
);
}
#[test]
fn test_hyphenated_word_before_code() {
let config = MD063Config {
enabled: true,
style: HeadingCapStyle::TitleCase,
lowercase_words: vec!["a".to_string(), "the".to_string(), "with".to_string()],
..Default::default()
};
let rule = MD063HeadingCapitalization::from_config_struct(config);
let content = "## Self-contained with a `feature`\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert!(
fixed.contains("with a `feature`"),
"Expected 'with a `feature`' but got: {fixed:?}"
);
}
#[test]
fn test_sentence_case_code_at_start_basic() {
let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
let content = "# `rumdl` is a linter\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Heading with code at start should not flag 'is' for capitalization. Got: {:?}",
result.iter().map(|w| &w.message).collect::<Vec<_>>()
);
}
#[test]
fn test_sentence_case_code_at_start_incorrect_capitalization() {
let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
let content = "# `rumdl` Is a Linter\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1, "Should detect incorrect capitalization");
assert!(
result[0].message.contains("`rumdl` is a linter"),
"Should suggest lowercase after code. Got: {:?}",
result[0].message
);
}
#[test]
fn test_sentence_case_code_at_start_fix() {
let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
let content = "# `rumdl` Is A Linter\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert!(
fixed.contains("# `rumdl` is a linter"),
"Should fix to lowercase after code. Got: {fixed:?}"
);
}
#[test]
fn test_sentence_case_text_at_start_still_capitalizes() {
let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
let content = "# the quick brown fox\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1);
assert!(
result[0].message.contains("The quick brown fox"),
"Text-first heading should capitalize first word. Got: {:?}",
result[0].message
);
}
#[test]
fn test_sentence_case_link_at_start() {
let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
let content = "# [api](api.md) reference guide\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Heading with link at start should not capitalize 'reference'. Got: {:?}",
result.iter().map(|w| &w.message).collect::<Vec<_>>()
);
}
#[test]
fn test_sentence_case_link_preserves_acronyms() {
let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
let content = "# [API](api.md) Reference Guide\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1);
assert!(
result[0].message.contains("[API](api.md) reference guide"),
"Should preserve acronym 'API' but lowercase following text. Got: {:?}",
result[0].message
);
}
#[test]
fn test_sentence_case_link_preserves_brand_names() {
let config = MD063Config {
enabled: true,
style: HeadingCapStyle::SentenceCase,
preserve_cased_words: true,
..Default::default()
};
let rule = MD063HeadingCapitalization::from_config_struct(config);
let content = "# [iPhone](iphone.md) Features Guide\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1);
assert!(
result[0].message.contains("[iPhone](iphone.md) features guide"),
"Should preserve 'iPhone' but lowercase following text. Got: {:?}",
result[0].message
);
}
#[test]
fn test_sentence_case_link_lowercases_regular_words() {
let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
let content = "# [Documentation](docs.md) Reference\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1);
assert!(
result[0].message.contains("[documentation](docs.md) reference"),
"Should lowercase regular link text. Got: {:?}",
result[0].message
);
}
#[test]
fn test_sentence_case_link_at_start_correct_already() {
let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
let content = "# [API](api.md) reference guide\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Correctly cased heading with link should not be flagged. Got: {:?}",
result.iter().map(|w| &w.message).collect::<Vec<_>>()
);
}
#[test]
fn test_sentence_case_link_github_preserved() {
let config = MD063Config {
enabled: true,
style: HeadingCapStyle::SentenceCase,
preserve_cased_words: true,
..Default::default()
};
let rule = MD063HeadingCapitalization::from_config_struct(config);
let content = "# [GitHub](gh.md) Repository Setup\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1);
assert!(
result[0].message.contains("[GitHub](gh.md) repository setup"),
"Should preserve 'GitHub'. Got: {:?}",
result[0].message
);
}
#[test]
fn test_sentence_case_multiple_code_spans() {
let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
let content = "# `foo` and `bar` are methods\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Should not capitalize words between/after code spans. Got: {:?}",
result.iter().map(|w| &w.message).collect::<Vec<_>>()
);
}
#[test]
fn test_sentence_case_code_only_heading() {
let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
let content = "# `rumdl`\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Code-only heading should be fine. Got: {:?}",
result.iter().map(|w| &w.message).collect::<Vec<_>>()
);
}
#[test]
fn test_sentence_case_code_at_end() {
let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
let content = "# install the `rumdl` tool\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1);
assert!(
result[0].message.contains("Install the `rumdl` tool"),
"First word should still be capitalized when text comes first. Got: {:?}",
result[0].message
);
}
#[test]
fn test_sentence_case_code_in_middle() {
let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
let content = "# using the `rumdl` linter for markdown\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1);
assert!(
result[0].message.contains("Using the `rumdl` linter for markdown"),
"First word should be capitalized. Got: {:?}",
result[0].message
);
}
#[test]
fn test_sentence_case_preserved_word_after_code() {
let config = MD063Config {
enabled: true,
style: HeadingCapStyle::SentenceCase,
preserve_cased_words: true,
..Default::default()
};
let rule = MD063HeadingCapitalization::from_config_struct(config);
let content = "# `swift` iPhone development\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Preserved words after code should stay. Got: {:?}",
result.iter().map(|w| &w.message).collect::<Vec<_>>()
);
}
#[test]
fn test_title_case_code_at_start_still_capitalizes() {
let rule = create_rule_with_style(HeadingCapStyle::TitleCase);
let content = "# `api` quick start guide\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1);
assert!(
result[0].message.contains("Quick Start Guide") || result[0].message.contains("quick Start Guide"),
"Title case should capitalize major words after code. Got: {:?}",
result[0].message
);
}
#[test]
fn test_sentence_case_html_tag_at_start() {
let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
let content = "# <kbd>Ctrl</kbd> is a Modifier Key\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(
fixed, "# <kbd>Ctrl</kbd> is a modifier key\n",
"Text after HTML at start should be lowercase"
);
}
#[test]
fn test_sentence_case_html_tag_preserves_content() {
let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
let content = "# The <abbr>API</abbr> documentation guide\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"HTML tag content should be preserved. Got: {:?}",
result.iter().map(|w| &w.message).collect::<Vec<_>>()
);
}
#[test]
fn test_sentence_case_html_tag_at_start_with_acronym() {
let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
let content = "# <abbr>API</abbr> Documentation Guide\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(
fixed, "# <abbr>API</abbr> documentation guide\n",
"Text after HTML at start should be lowercase, HTML content preserved"
);
}
#[test]
fn test_sentence_case_html_tag_in_middle() {
let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
let content = "# using the <code>config</code> File\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(
fixed, "# Using the <code>config</code> file\n",
"First word capitalized, HTML preserved, rest lowercase"
);
}
#[test]
fn test_html_tag_strong_emphasis() {
let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
let content = "# The <strong>Bold</strong> Way\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(
fixed, "# The <strong>Bold</strong> way\n",
"<strong> tag content should be preserved"
);
}
#[test]
fn test_html_tag_with_attributes() {
let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
let content = "# <span class=\"highlight\">Important</span> Notice Here\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(
fixed, "# <span class=\"highlight\">Important</span> notice here\n",
"HTML tag with attributes should be preserved"
);
}
#[test]
fn test_multiple_html_tags() {
let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
let content = "# <kbd>Ctrl</kbd>+<kbd>C</kbd> to Copy Text\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(
fixed, "# <kbd>Ctrl</kbd>+<kbd>C</kbd> to copy text\n",
"Multiple HTML tags should all be preserved"
);
}
#[test]
fn test_html_and_code_mixed() {
let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
let content = "# <kbd>Ctrl</kbd>+`v` Paste command\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(
fixed, "# <kbd>Ctrl</kbd>+`v` paste command\n",
"HTML and code should both be preserved"
);
}
#[test]
fn test_self_closing_html_tag() {
let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
let content = "# Line one<br/>Line Two Here\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(
fixed, "# Line one<br/>line two here\n",
"Self-closing HTML tags should be preserved"
);
}
#[test]
fn test_title_case_with_html_tags() {
let rule = create_rule_with_style(HeadingCapStyle::TitleCase);
let content = "# the <kbd>ctrl</kbd> key is a modifier\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1);
let fixed = rule.fix(&ctx).unwrap();
assert!(
fixed.contains("<kbd>ctrl</kbd>"),
"HTML tag content should be preserved in title case. Got: {fixed}"
);
assert!(
fixed.starts_with("# The ") || fixed.starts_with("# the "),
"Title case should work with HTML. Got: {fixed}"
);
}
#[test]
fn test_sentence_case_preserves_caret_notation() {
let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
let content = "## Ctrl+A, Ctrl+R output ^A, ^R on zsh\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Caret notation should be preserved. Got: {:?}",
result.iter().map(|w| &w.message).collect::<Vec<_>>()
);
}
#[test]
fn test_sentence_case_caret_notation_various() {
let rule = create_rule_with_style(HeadingCapStyle::SentenceCase);
let content = "## Press ^C to cancel\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"^C should be preserved. Got: {:?}",
result.iter().map(|w| &w.message).collect::<Vec<_>>()
);
let content = "## Use ^Z for background\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"^Z should be preserved. Got: {:?}",
result.iter().map(|w| &w.message).collect::<Vec<_>>()
);
let content = "## Press ^[ for escape\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"^[ should be preserved. Got: {:?}",
result.iter().map(|w| &w.message).collect::<Vec<_>>()
);
}
#[test]
fn test_caret_notation_detection() {
let rule = create_rule();
assert!(rule.is_caret_notation("^A"));
assert!(rule.is_caret_notation("^Z"));
assert!(rule.is_caret_notation("^C"));
assert!(rule.is_caret_notation("^@")); assert!(rule.is_caret_notation("^[")); assert!(rule.is_caret_notation("^]")); assert!(rule.is_caret_notation("^^")); assert!(rule.is_caret_notation("^_"));
assert!(!rule.is_caret_notation("^a")); assert!(!rule.is_caret_notation("A")); assert!(!rule.is_caret_notation("^")); assert!(!rule.is_caret_notation("^1")); }
fn create_sentence_case_rule_with_proper_names(names: Vec<String>) -> MD063HeadingCapitalization {
let config = MD063Config {
enabled: true,
style: HeadingCapStyle::SentenceCase,
..Default::default()
};
let mut rule = MD063HeadingCapitalization::from_config_struct(config);
rule.proper_names = names;
rule
}
#[test]
fn test_sentence_case_preserves_single_word_proper_name() {
let rule = create_sentence_case_rule_with_proper_names(vec!["JavaScript".to_string()]);
let content = "# installing javascript\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1, "Should flag the heading");
let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
assert!(
fix_text.contains("JavaScript"),
"Fix should preserve proper name 'JavaScript', got: {fix_text:?}"
);
assert!(
!fix_text.contains("javascript"),
"Fix should not have lowercase 'javascript', got: {fix_text:?}"
);
}
#[test]
fn test_sentence_case_preserves_multi_word_proper_name() {
let rule = create_sentence_case_rule_with_proper_names(vec!["Good Application".to_string()]);
let content = "# using good application features\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1, "Should flag the heading");
let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
assert!(
fix_text.contains("Good Application"),
"Fix should preserve 'Good Application' as a phrase, got: {fix_text:?}"
);
}
#[test]
fn test_sentence_case_proper_name_at_start_of_heading() {
let rule = create_sentence_case_rule_with_proper_names(vec!["Good Application".to_string()]);
let content = "# good application overview\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1, "Should flag the heading");
let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
assert!(
fix_text.contains("Good Application"),
"Fix should produce 'Good Application' at start of heading, got: {fix_text:?}"
);
assert!(
fix_text.contains("overview"),
"Non-proper-name word 'overview' should be lowercase, got: {fix_text:?}"
);
}
#[test]
fn test_sentence_case_with_proper_names_no_oscillation() {
let rule = create_sentence_case_rule_with_proper_names(vec!["Good Application".to_string()]);
let content = "# installing good application on your system\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1);
let fixed_heading = result[0].fix.as_ref().unwrap().replacement.as_str();
assert!(
fixed_heading.contains("Good Application"),
"After fix, proper name must be preserved: {fixed_heading:?}"
);
let fixed_line = format!("{fixed_heading}\n");
let ctx2 = LintContext::new(&fixed_line, crate::config::MarkdownFlavor::Standard, None);
let result2 = rule.check(&ctx2).unwrap();
assert!(
result2.is_empty(),
"After one fix, heading must already satisfy both MD063 and MD044 - no oscillation. \
Second pass warnings: {result2:?}"
);
}
#[test]
fn test_sentence_case_proper_names_already_correct() {
let rule = create_sentence_case_rule_with_proper_names(vec!["Good Application".to_string()]);
let content = "# Installing Good Application\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Correct sentence-case heading with proper name should not be flagged, got: {result:?}"
);
}
#[test]
fn test_sentence_case_multiple_proper_names_in_heading() {
let rule = create_sentence_case_rule_with_proper_names(vec!["TypeScript".to_string(), "React".to_string()]);
let content = "# using typescript with react\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1);
let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
assert!(
fix_text.contains("TypeScript"),
"Fix should preserve 'TypeScript', got: {fix_text:?}"
);
assert!(
fix_text.contains("React"),
"Fix should preserve 'React', got: {fix_text:?}"
);
}
#[test]
fn test_sentence_case_unicode_casefold_expansion_before_proper_name() {
let rule = create_sentence_case_rule_with_proper_names(vec!["Österreich".to_string()]);
let content = "# İ österreich guide\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1, "Should flag heading for canonical proper-name casing");
let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
assert!(
fix_text.contains("Österreich"),
"Fix should preserve canonical 'Österreich', got: {fix_text:?}"
);
}
#[test]
fn test_sentence_case_preserves_trailing_punctuation_on_proper_name() {
let rule = create_sentence_case_rule_with_proper_names(vec!["JavaScript".to_string()]);
let content = "# using javascript, today\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1, "Should flag heading");
let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
assert!(
fix_text.contains("JavaScript,"),
"Fix should preserve trailing punctuation, got: {fix_text:?}"
);
}
fn create_title_case_rule_with_proper_names(names: Vec<String>) -> MD063HeadingCapitalization {
let config = MD063Config {
enabled: true,
style: HeadingCapStyle::TitleCase,
..Default::default()
};
let mut rule = MD063HeadingCapitalization::from_config_struct(config);
rule.proper_names = names;
rule
}
#[test]
fn test_title_case_preserves_proper_name_with_lowercase_article() {
let rule = create_title_case_rule_with_proper_names(vec!["The Rolling Stones".to_string()]);
let content = "# listening to the rolling stones today\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1, "Should flag the heading");
let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
assert!(
fix_text.contains("The Rolling Stones"),
"Fix should preserve proper name 'The Rolling Stones', got: {fix_text:?}"
);
}
#[test]
fn test_title_case_proper_name_no_oscillation() {
let rule = create_title_case_rule_with_proper_names(vec!["The Rolling Stones".to_string()]);
let content = "# listening to the rolling stones today\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1);
let fixed_heading = result[0].fix.as_ref().unwrap().replacement.as_str();
let fixed_line = format!("{fixed_heading}\n");
let ctx2 = LintContext::new(&fixed_line, crate::config::MarkdownFlavor::Standard, None);
let result2 = rule.check(&ctx2).unwrap();
assert!(
result2.is_empty(),
"After one title-case fix, heading must already satisfy both rules. \
Second pass warnings: {result2:?}"
);
}
#[test]
fn test_title_case_unicode_casefold_expansion_before_proper_name() {
let rule = create_title_case_rule_with_proper_names(vec!["Österreich".to_string()]);
let content = "# İ österreich guide\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1, "Should flag the heading");
let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
assert!(
fix_text.contains("Österreich"),
"Fix should preserve canonical proper-name casing, got: {fix_text:?}"
);
}
#[test]
fn test_from_config_loads_md044_names_into_md063() {
use crate::config::{Config, RuleConfig};
use crate::rule::Rule;
use std::collections::BTreeMap;
let mut config = Config::default();
let mut md063_values = BTreeMap::new();
md063_values.insert("style".to_string(), toml::Value::String("sentence_case".to_string()));
md063_values.insert("enabled".to_string(), toml::Value::Boolean(true));
config.rules.insert(
"MD063".to_string(),
RuleConfig {
values: md063_values,
severity: None,
},
);
let mut md044_values = BTreeMap::new();
md044_values.insert(
"names".to_string(),
toml::Value::Array(vec![toml::Value::String("Good Application".to_string())]),
);
config.rules.insert(
"MD044".to_string(),
RuleConfig {
values: md044_values,
severity: None,
},
);
let rule = MD063HeadingCapitalization::from_config(&config);
let content = "# using good application features\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1, "Should flag the heading");
let fix_text = result[0].fix.as_ref().unwrap().replacement.as_str();
assert!(
fix_text.contains("Good Application"),
"from_config should wire MD044 names into MD063; fix should preserve \
'Good Application', got: {fix_text:?}"
);
}
#[test]
fn test_title_case_short_word_not_confused_with_substring() {
let rule = create_rule_with_style(HeadingCapStyle::TitleCase);
let content = "# in the insert\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1, "Should flag the heading");
let fix = result[0].fix.as_ref().expect("Fix should be present");
assert!(
fix.replacement.contains("In the Insert"),
"Expected 'In the Insert', got: {:?}",
fix.replacement
);
}
#[test]
fn test_title_case_or_not_confused_with_orchestra() {
let rule = create_rule_with_style(HeadingCapStyle::TitleCase);
let content = "# or the orchestra\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1, "Should flag the heading");
let fix = result[0].fix.as_ref().expect("Fix should be present");
assert!(
fix.replacement.contains("Or the Orchestra"),
"Expected 'Or the Orchestra', got: {:?}",
fix.replacement
);
}
#[test]
fn test_all_caps_preserves_all_words() {
let rule = create_rule_with_style(HeadingCapStyle::AllCaps);
let content = "# in the insert\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1, "Should flag the heading");
let fix = result[0].fix.as_ref().expect("Fix should be present");
assert!(
fix.replacement.contains("IN THE INSERT"),
"All caps should uppercase all words, got: {:?}",
fix.replacement
);
}
}