use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
use crate::rule_config_serde::RuleConfig;
use crate::rules::front_matter_utils::{FrontMatterType, FrontMatterUtils};
use regex::Regex;
use serde::{Deserialize, Serialize};
use std::sync::LazyLock;
static JSON_KEY_PATTERN: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r#"^\s*"([^"]+)"\s*:"#).expect("Invalid JSON key regex"));
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct MD072Config {
#[serde(default)]
pub enabled: bool,
#[serde(default, alias = "key-order")]
pub key_order: Option<Vec<String>>,
#[serde(default, alias = "required-keys")]
pub required_keys: Vec<String>,
}
impl RuleConfig for MD072Config {
const RULE_NAME: &'static str = "MD072";
}
#[derive(Clone, Default)]
pub struct MD072FrontmatterKeySort {
config: MD072Config,
}
impl MD072FrontmatterKeySort {
pub fn new() -> Self {
Self::default()
}
pub fn from_config_struct(config: MD072Config) -> Self {
Self { config }
}
fn has_comments(frontmatter_lines: &[&str]) -> bool {
frontmatter_lines.iter().any(|line| line.trim_start().starts_with('#'))
}
fn extract_yaml_keys(frontmatter_lines: &[&str]) -> Vec<(usize, String)> {
let mut keys = Vec::new();
for (idx, line) in frontmatter_lines.iter().enumerate() {
if !line.starts_with(' ')
&& !line.starts_with('\t')
&& let Some(colon_pos) = Self::separator_pos_outside_quoted_key(line, ':')
{
let raw = line[..colon_pos].trim();
if !raw.is_empty() && !raw.starts_with('#') {
let key = raw
.strip_prefix('"')
.and_then(|k| k.strip_suffix('"'))
.or_else(|| raw.strip_prefix('\'').and_then(|k| k.strip_suffix('\'')))
.unwrap_or(raw);
keys.push((idx, key.to_string()));
}
}
}
keys
}
fn extract_toml_keys(frontmatter_lines: &[&str]) -> Vec<(usize, String)> {
let mut keys = Vec::new();
for (idx, line) in frontmatter_lines.iter().enumerate() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
if trimmed.starts_with('[') {
break;
}
if !line.starts_with(' ')
&& !line.starts_with('\t')
&& let Some(eq_pos) = Self::separator_pos_outside_quoted_key(line, '=')
{
let raw = line[..eq_pos].trim();
if !raw.is_empty() {
let key = raw
.strip_prefix('"')
.and_then(|k| k.strip_suffix('"'))
.or_else(|| raw.strip_prefix('\'').and_then(|k| k.strip_suffix('\'')))
.unwrap_or(raw);
keys.push((idx, key.to_string()));
}
}
}
keys
}
fn extract_json_keys(frontmatter_lines: &[&str]) -> Vec<String> {
let mut keys = Vec::new();
let mut depth: usize = 0;
for line in frontmatter_lines {
let line_start_depth = depth;
let mut in_string = false;
let mut prev_backslash = false;
for ch in line.chars() {
if in_string {
if ch == '"' && !prev_backslash {
in_string = false;
}
prev_backslash = ch == '\\' && !prev_backslash;
} else {
match ch {
'"' => in_string = true,
'{' | '[' => depth += 1,
'}' | ']' => depth = depth.saturating_sub(1),
_ => {}
}
prev_backslash = false;
}
}
if line_start_depth == 0
&& let Some(captures) = JSON_KEY_PATTERN.captures(line)
&& let Some(key_match) = captures.get(1)
{
keys.push(key_match.as_str().to_string());
}
}
keys
}
fn key_sort_position(key: &str, key_order: Option<&[String]>) -> (usize, String) {
if let Some(order) = key_order {
let key_lower = key.to_lowercase();
for (idx, ordered_key) in order.iter().enumerate() {
if ordered_key.to_lowercase() == key_lower {
return (idx, key_lower);
}
}
(usize::MAX, key_lower)
} else {
(0, key.to_lowercase())
}
}
fn find_first_unsorted_pair<'a>(keys: &'a [String], key_order: Option<&[String]>) -> Option<(&'a str, &'a str)> {
for i in 1..keys.len() {
let pos_curr = Self::key_sort_position(&keys[i], key_order);
let pos_prev = Self::key_sort_position(&keys[i - 1], key_order);
if pos_curr < pos_prev {
return Some((&keys[i], &keys[i - 1]));
}
}
None
}
fn find_first_unsorted_indexed_pair<'a>(
keys: &'a [(usize, String)],
key_order: Option<&[String]>,
) -> Option<(usize, &'a str, &'a str)> {
for i in 1..keys.len() {
let pos_curr = Self::key_sort_position(&keys[i].1, key_order);
let pos_prev = Self::key_sort_position(&keys[i - 1].1, key_order);
if pos_curr < pos_prev {
return Some((keys[i].0, &keys[i].1, &keys[i - 1].1));
}
}
None
}
fn are_keys_sorted(keys: &[String], key_order: Option<&[String]>) -> bool {
Self::find_first_unsorted_pair(keys, key_order).is_none()
}
fn are_indexed_keys_sorted(keys: &[(usize, String)], key_order: Option<&[String]>) -> bool {
Self::find_first_unsorted_indexed_pair(keys, key_order).is_none()
}
fn sort_keys_by_order(keys: &mut [(String, Vec<&str>)], key_order: Option<&[String]>) {
keys.sort_by(|a, b| {
let pos_a = Self::key_sort_position(&a.0, key_order);
let pos_b = Self::key_sort_position(&b.0, key_order);
pos_a.cmp(&pos_b)
});
}
fn separator_pos_outside_quoted_key(line: &str, separator: char) -> Option<usize> {
let after_quote = if let Some(rest) = line.strip_prefix('"') {
rest.find('"').map(|i| i + 2)
} else if let Some(rest) = line.strip_prefix('\'') {
rest.find('\'').map(|i| i + 2)
} else {
None
};
match after_quote {
Some(start) => line[start..].find(separator).map(|i| start + i),
None => line.find(separator),
}
}
fn toml_root_key(raw: &str) -> &str {
if let Some(rest) = raw.strip_prefix('"') {
if let Some(end) = rest.find('"') {
return &rest[..end];
}
} else if let Some(rest) = raw.strip_prefix('\'')
&& let Some(end) = rest.find('\'')
{
return &rest[..end];
}
raw.split('.').next().unwrap_or(raw).trim()
}
fn extract_toml_presence_keys(frontmatter_lines: &[&str]) -> Vec<String> {
let mut keys = Vec::new();
let mut in_tables = false;
for line in frontmatter_lines {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
if trimmed.starts_with('[') {
in_tables = true;
let inner = trimmed
.strip_prefix("[[")
.and_then(|s| s.split_once("]]").map(|(inner, _)| inner))
.or_else(|| {
trimmed
.strip_prefix('[')
.and_then(|s| s.split_once(']').map(|(inner, _)| inner))
});
if let Some(inner) = inner {
let key = Self::toml_root_key(inner.trim());
if !key.is_empty() {
keys.push(key.to_string());
}
}
continue;
}
if !in_tables
&& !line.starts_with(' ')
&& !line.starts_with('\t')
&& let Some(eq_pos) = Self::separator_pos_outside_quoted_key(line, '=')
{
let key = Self::toml_root_key(line[..eq_pos].trim());
if !key.is_empty() {
keys.push(key.to_string());
}
}
}
keys
}
fn parse_json_top_level_keys(frontmatter_lines: &[&str]) -> Option<Vec<String>> {
let json_content = format!("{{{}}}", frontmatter_lines.join("\n"));
match serde_json::from_str::<serde_json::Value>(&json_content) {
Ok(serde_json::Value::Object(map)) => Some(map.keys().cloned().collect()),
_ => None,
}
}
fn missing_required_key_warnings(
&self,
present_keys: &[String],
format: &str,
fence_len: usize,
fm_end_line: usize,
) -> Vec<LintWarning> {
if self.config.required_keys.is_empty() {
return Vec::new();
}
let present: Vec<String> = present_keys.iter().map(|k| k.to_lowercase()).collect();
self.config
.required_keys
.iter()
.filter(|required| !present.contains(&required.to_lowercase()))
.map(|required| LintWarning {
rule_name: Some(self.name().to_string()),
message: format!("{format} frontmatter is missing required key '{required}'"),
line: 1,
column: 1,
end_line: fm_end_line.max(1),
end_column: fence_len + 1,
severity: Severity::Warning,
fix: None,
})
.collect()
}
}
impl Rule for MD072FrontmatterKeySort {
fn name(&self) -> &'static str {
"MD072"
}
fn description(&self) -> &'static str {
"Frontmatter keys should be sorted alphabetically"
}
fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
let content = ctx.content;
let mut warnings = Vec::new();
if content.is_empty() {
return Ok(warnings);
}
let fm_type = FrontMatterUtils::detect_front_matter_type(content);
match fm_type {
FrontMatterType::Yaml => {
let frontmatter_lines = FrontMatterUtils::extract_front_matter(content);
let keys = Self::extract_yaml_keys(&frontmatter_lines);
let key_names: Vec<String> = keys.iter().map(|(_, key)| key.clone()).collect();
warnings.extend(self.missing_required_key_warnings(&key_names, "YAML", 3, ctx.front_matter_end_line()));
if frontmatter_lines.is_empty() {
return Ok(warnings);
}
let key_order = self.config.key_order.as_deref();
let Some((key_idx, out_of_place, should_come_after)) =
Self::find_first_unsorted_indexed_pair(&keys, key_order)
else {
return Ok(warnings);
};
let key_line = key_idx + 2;
let has_comments = Self::has_comments(&frontmatter_lines);
let fix = if has_comments {
None
} else {
let fixed_content = self.fix_yaml(content, ctx.front_matter_end_line());
if fixed_content != content {
Some(Fix::new(0..content.len(), fixed_content))
} else {
None
}
};
let message = if has_comments {
format!(
"YAML frontmatter keys are not sorted alphabetically: '{out_of_place}' should come before '{should_come_after}' (auto-fix unavailable: contains comments)"
)
} else {
format!(
"YAML frontmatter keys are not sorted alphabetically: '{out_of_place}' should come before '{should_come_after}'"
)
};
let end_column = frontmatter_lines
.get(key_idx)
.and_then(|line| {
Self::separator_pos_outside_quoted_key(line, ':')
.map(|pos| line[..pos].trim().chars().count() + 1)
})
.unwrap_or(out_of_place.chars().count() + 1);
warnings.push(LintWarning {
rule_name: Some(self.name().to_string()),
message,
line: key_line,
column: 1,
end_line: key_line,
end_column,
severity: Severity::Warning,
fix,
});
}
FrontMatterType::Toml => {
let frontmatter_lines = FrontMatterUtils::extract_front_matter(content);
let keys = Self::extract_toml_keys(&frontmatter_lines);
let key_names = Self::extract_toml_presence_keys(&frontmatter_lines);
warnings.extend(self.missing_required_key_warnings(&key_names, "TOML", 3, ctx.front_matter_end_line()));
if frontmatter_lines.is_empty() {
return Ok(warnings);
}
let key_order = self.config.key_order.as_deref();
let Some((key_idx, out_of_place, should_come_after)) =
Self::find_first_unsorted_indexed_pair(&keys, key_order)
else {
return Ok(warnings);
};
let key_line = key_idx + 2;
let has_comments = Self::has_comments(&frontmatter_lines);
let fix = if has_comments {
None
} else {
let fixed_content = self.fix_toml(content, ctx.front_matter_end_line());
if fixed_content != content {
Some(Fix::new(0..content.len(), fixed_content))
} else {
None
}
};
let message = if has_comments {
format!(
"TOML frontmatter keys are not sorted alphabetically: '{out_of_place}' should come before '{should_come_after}' (auto-fix unavailable: contains comments)"
)
} else {
format!(
"TOML frontmatter keys are not sorted alphabetically: '{out_of_place}' should come before '{should_come_after}'"
)
};
let end_column = frontmatter_lines
.get(key_idx)
.and_then(|line| {
Self::separator_pos_outside_quoted_key(line, '=')
.map(|pos| line[..pos].trim().chars().count() + 1)
})
.unwrap_or(out_of_place.chars().count() + 1);
warnings.push(LintWarning {
rule_name: Some(self.name().to_string()),
message,
line: key_line,
column: 1,
end_line: key_line,
end_column,
severity: Severity::Warning,
fix,
});
}
FrontMatterType::Json => {
let frontmatter_lines = FrontMatterUtils::extract_front_matter(content);
let keys = Self::extract_json_keys(&frontmatter_lines);
let parsed_keys = Self::parse_json_top_level_keys(&frontmatter_lines);
warnings.extend(self.missing_required_key_warnings(
parsed_keys.as_deref().unwrap_or(&keys),
"JSON",
1,
ctx.front_matter_end_line(),
));
if frontmatter_lines.is_empty() {
return Ok(warnings);
}
let key_order = self.config.key_order.as_deref();
let Some((out_of_place, should_come_after)) = Self::find_first_unsorted_pair(&keys, key_order) else {
return Ok(warnings);
};
let fixed_content = self.fix_json(content, ctx.front_matter_end_line());
let fix = if fixed_content != content {
Some(Fix::new(0..content.len(), fixed_content))
} else {
None
};
let message = format!(
"JSON frontmatter keys are not sorted alphabetically: '{out_of_place}' should come before '{should_come_after}'"
);
warnings.push(LintWarning {
rule_name: Some(self.name().to_string()),
message,
line: 2,
column: 1,
end_line: 2,
end_column: out_of_place.len() + 1,
severity: Severity::Warning,
fix,
});
}
_ => {
}
}
Ok(warnings)
}
fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
let content = ctx.content;
if ctx.is_rule_disabled(self.name(), 2) {
return Ok(content.to_string());
}
let fm_type = FrontMatterUtils::detect_front_matter_type(content);
let fm_end = ctx.front_matter_end_line();
Ok(match fm_type {
FrontMatterType::Yaml => self.fix_yaml(content, fm_end),
FrontMatterType::Toml => self.fix_toml(content, fm_end),
FrontMatterType::Json => self.fix_json(content, fm_end),
_ => content.to_string(),
})
}
fn category(&self) -> RuleCategory {
RuleCategory::FrontMatter
}
fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
ctx.content.is_empty()
|| !ctx.content.starts_with("---") && !ctx.content.starts_with("+++") && !ctx.content.starts_with('{')
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
crate::impl_rule_config_methods!(MD072Config, nullable);
}
impl MD072FrontmatterKeySort {
fn preserve_trailing_newline(original: &str, mut result: String) -> String {
if original.ends_with('\n') && !result.ends_with('\n') {
result.push('\n');
}
result
}
fn fix_yaml(&self, content: &str, fm_end: usize) -> String {
let frontmatter_lines = FrontMatterUtils::extract_front_matter(content);
if frontmatter_lines.is_empty() {
return content.to_string();
}
if Self::has_comments(&frontmatter_lines) {
return content.to_string();
}
let keys = Self::extract_yaml_keys(&frontmatter_lines);
let key_order = self.config.key_order.as_deref();
if Self::are_indexed_keys_sorted(&keys, key_order) {
return content.to_string();
}
let mut key_blocks: Vec<(String, Vec<&str>)> = Vec::new();
for (i, (line_idx, key)) in keys.iter().enumerate() {
let start = *line_idx;
let end = if i + 1 < keys.len() {
keys[i + 1].0
} else {
frontmatter_lines.len()
};
let block_lines: Vec<&str> = frontmatter_lines[start..end].to_vec();
key_blocks.push((key.clone(), block_lines));
}
Self::sort_keys_by_order(&mut key_blocks, key_order);
let content_lines: Vec<&str> = content.lines().collect();
let mut result = String::new();
result.push_str("---\n");
for (_, lines) in &key_blocks {
for line in lines {
result.push_str(line);
result.push('\n');
}
}
result.push_str("---");
if fm_end < content_lines.len() {
result.push('\n');
result.push_str(&content_lines[fm_end..].join("\n"));
}
Self::preserve_trailing_newline(content, result)
}
fn fix_toml(&self, content: &str, fm_end: usize) -> String {
let frontmatter_lines = FrontMatterUtils::extract_front_matter(content);
if frontmatter_lines.is_empty() {
return content.to_string();
}
if Self::has_comments(&frontmatter_lines) {
return content.to_string();
}
let keys = Self::extract_toml_keys(&frontmatter_lines);
let key_order = self.config.key_order.as_deref();
if Self::are_indexed_keys_sorted(&keys, key_order) {
return content.to_string();
}
let mut key_blocks: Vec<(String, Vec<&str>)> = Vec::new();
for (i, (line_idx, key)) in keys.iter().enumerate() {
let start = *line_idx;
let end = if i + 1 < keys.len() {
keys[i + 1].0
} else {
frontmatter_lines.len()
};
let block_lines: Vec<&str> = frontmatter_lines[start..end].to_vec();
key_blocks.push((key.clone(), block_lines));
}
Self::sort_keys_by_order(&mut key_blocks, key_order);
let content_lines: Vec<&str> = content.lines().collect();
let mut result = String::new();
result.push_str("+++\n");
for (_, lines) in &key_blocks {
for line in lines {
result.push_str(line);
result.push('\n');
}
}
result.push_str("+++");
if fm_end < content_lines.len() {
result.push('\n');
result.push_str(&content_lines[fm_end..].join("\n"));
}
Self::preserve_trailing_newline(content, result)
}
fn fix_json(&self, content: &str, fm_end: usize) -> String {
let frontmatter_lines = FrontMatterUtils::extract_front_matter(content);
if frontmatter_lines.is_empty() {
return content.to_string();
}
let keys = Self::extract_json_keys(&frontmatter_lines);
let key_order = self.config.key_order.as_deref();
if keys.is_empty() || Self::are_keys_sorted(&keys, key_order) {
return content.to_string();
}
let json_content = format!("{{{}}}", frontmatter_lines.join("\n"));
match serde_json::from_str::<serde_json::Value>(&json_content) {
Ok(serde_json::Value::Object(map)) => {
let mut sorted_map = serde_json::Map::new();
let mut keys: Vec<_> = map.keys().cloned().collect();
keys.sort_by(|a, b| {
let pos_a = Self::key_sort_position(a, key_order);
let pos_b = Self::key_sort_position(b, key_order);
pos_a.cmp(&pos_b)
});
for key in keys {
if let Some(value) = map.get(&key) {
sorted_map.insert(key, value.clone());
}
}
match serde_json::to_string_pretty(&serde_json::Value::Object(sorted_map)) {
Ok(sorted_json) => {
let lines: Vec<&str> = content.lines().collect();
let mut result = String::new();
result.push_str(&sorted_json);
if fm_end < lines.len() {
result.push('\n');
result.push_str(&lines[fm_end..].join("\n"));
}
Self::preserve_trailing_newline(content, result)
}
Err(_) => content.to_string(),
}
}
_ => content.to_string(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::lint_context::LintContext;
fn create_enabled_rule() -> MD072FrontmatterKeySort {
MD072FrontmatterKeySort::from_config_struct(MD072Config {
enabled: true,
..Default::default()
})
}
fn create_rule_with_key_order(keys: Vec<&str>) -> MD072FrontmatterKeySort {
MD072FrontmatterKeySort::from_config_struct(MD072Config {
enabled: true,
key_order: Some(keys.into_iter().map(String::from).collect()),
..Default::default()
})
}
fn create_rule_with_required_keys(keys: Vec<&str>) -> MD072FrontmatterKeySort {
MD072FrontmatterKeySort::from_config_struct(MD072Config {
enabled: true,
required_keys: keys.into_iter().map(String::from).collect(),
..Default::default()
})
}
#[test]
fn test_enabled_via_config() {
let rule = create_enabled_rule();
let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1);
}
#[test]
fn test_no_frontmatter() {
let rule = create_enabled_rule();
let content = "# Heading\n\nContent.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty());
}
#[test]
fn test_yaml_sorted_keys() {
let rule = create_enabled_rule();
let content = "---\nauthor: John\ndate: 2024-01-01\ntitle: Test\n---\n\n# Heading";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty());
}
#[test]
fn test_yaml_unsorted_keys() {
let rule = create_enabled_rule();
let content = "---\ntitle: Test\nauthor: John\ndate: 2024-01-01\n---\n\n# Heading";
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("YAML"));
assert!(result[0].message.contains("not sorted"));
assert!(result[0].message.contains("'author' should come before 'title'"));
}
#[test]
fn test_yaml_case_insensitive_sort() {
let rule = create_enabled_rule();
let content = "---\nAuthor: John\ndate: 2024-01-01\nTitle: Test\n---\n\n# Heading";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty());
}
#[test]
fn test_yaml_fix_sorts_keys() {
let rule = create_enabled_rule();
let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
let author_pos = fixed.find("author:").unwrap();
let title_pos = fixed.find("title:").unwrap();
assert!(author_pos < title_pos);
}
#[test]
fn test_yaml_no_fix_with_comments() {
let rule = create_enabled_rule();
let content = "---\ntitle: Test\n# This is a comment\nauthor: John\n---\n\n# Heading";
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("auto-fix unavailable"));
assert!(result[0].fix.is_none());
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, content);
}
#[test]
fn test_yaml_single_key() {
let rule = create_enabled_rule();
let content = "---\ntitle: Test\n---\n\n# Heading";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty());
}
#[test]
fn test_yaml_nested_keys_ignored() {
let rule = create_enabled_rule();
let content = "---\nauthor:\n name: John\n email: john@example.com\ntitle: Test\n---\n\n# Heading";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty());
}
#[test]
fn test_yaml_fix_idempotent() {
let rule = create_enabled_rule();
let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed_once = rule.fix(&ctx).unwrap();
let ctx2 = LintContext::new(&fixed_once, crate::config::MarkdownFlavor::Standard, None);
let fixed_twice = rule.fix(&ctx2).unwrap();
assert_eq!(fixed_once, fixed_twice);
}
#[test]
fn test_yaml_fix_preserves_trailing_newline() {
let rule = create_enabled_rule();
let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert!(
fixed.ends_with('\n'),
"trailing newline must be preserved, got {fixed:?}"
);
let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
let fixed_twice = rule.fix(&ctx2).unwrap();
assert_eq!(fixed, fixed_twice);
}
#[test]
fn test_yaml_fix_whole_file_frontmatter_preserves_trailing_newline() {
let rule = create_enabled_rule();
let content = "---\ntitle: Test\nauthor: John\n---\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert!(
fixed.ends_with('\n'),
"trailing newline must be preserved, got {fixed:?}"
);
}
#[test]
fn test_yaml_quoted_keys_sort_by_content() {
let rule = create_enabled_rule();
let content = "---\n\"zebra\": 1\napple: 2\n---\n\n# Heading";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1, "quoted key out of order must be flagged");
assert!(result[0].message.contains("'apple' should come before 'zebra'"));
}
#[test]
fn test_yaml_quoted_key_warning_span_covers_quotes() {
let rule = create_enabled_rule();
let content = "---\nbanana: 1\n\"apple\": 2\n---\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1);
let w = &result[0];
assert_eq!(w.line, 3);
assert_eq!(w.column, 1);
assert_eq!(w.end_column, 8, "diagnostic span must cover the quoted key");
}
#[test]
fn test_yaml_complex_values() {
let rule = create_enabled_rule();
let content =
"---\nauthor: John Doe\ntags:\n - rust\n - markdown\ntitle: \"Test: A Complex Title\"\n---\n\n# Heading";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty());
}
#[test]
fn test_toml_sorted_keys() {
let rule = create_enabled_rule();
let content = "+++\nauthor = \"John\"\ndate = \"2024-01-01\"\ntitle = \"Test\"\n+++\n\n# Heading";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty());
}
#[test]
fn test_toml_unsorted_keys() {
let rule = create_enabled_rule();
let content = "+++\ntitle = \"Test\"\nauthor = \"John\"\n+++\n\n# Heading";
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("TOML"));
assert!(result[0].message.contains("not sorted"));
}
#[test]
fn test_toml_fix_sorts_keys() {
let rule = create_enabled_rule();
let content = "+++\ntitle = \"Test\"\nauthor = \"John\"\n+++\n\n# Heading";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
let author_pos = fixed.find("author").unwrap();
let title_pos = fixed.find("title").unwrap();
assert!(author_pos < title_pos);
}
#[test]
fn test_toml_no_fix_with_comments() {
let rule = create_enabled_rule();
let content = "+++\ntitle = \"Test\"\n# This is a comment\nauthor = \"John\"\n+++\n\n# Heading";
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("auto-fix unavailable"));
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, content);
}
#[test]
fn test_json_sorted_keys() {
let rule = create_enabled_rule();
let content = "{\n\"author\": \"John\",\n\"title\": \"Test\"\n}\n\n# Heading";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty());
}
#[test]
fn test_json_unsorted_keys() {
let rule = create_enabled_rule();
let content = "{\n\"title\": \"Test\",\n\"author\": \"John\"\n}\n\n# Heading";
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("JSON"));
assert!(result[0].message.contains("not sorted"));
}
#[test]
fn test_json_fix_sorts_keys() {
let rule = create_enabled_rule();
let content = "{\n\"title\": \"Test\",\n\"author\": \"John\"\n}\n\n# Heading";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
let author_pos = fixed.find("author").unwrap();
let title_pos = fixed.find("title").unwrap();
assert!(author_pos < title_pos);
}
#[test]
fn test_json_always_fixable() {
let rule = create_enabled_rule();
let content = "{\n\"title\": \"Test\",\n\"author\": \"John\"\n}\n\n# Heading";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1);
assert!(result[0].fix.is_some()); assert!(!result[0].message.contains("Auto-fix unavailable"));
}
#[test]
fn test_empty_content() {
let rule = create_enabled_rule();
let content = "";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty());
}
#[test]
fn test_empty_frontmatter() {
let rule = create_enabled_rule();
let content = "---\n---\n\n# Heading";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty());
}
#[test]
fn test_toml_nested_tables_ignored() {
let rule = create_enabled_rule();
let content = "+++\ntitle = \"Programming\"\nsort_by = \"weight\"\n\n[extra]\nwe_have_extra = \"variables\"\n+++\n\n# Heading";
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("'sort_by' should come before 'title'"));
assert!(!result[0].message.contains("we_have_extra"));
}
#[test]
fn test_toml_nested_taxonomies_ignored() {
let rule = create_enabled_rule();
let content = "+++\ntitle = \"Test\"\ndate = \"2024-01-01\"\n\n[taxonomies]\ncategories = [\"test\"]\ntags = [\"foo\"]\n+++\n\n# Heading";
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("'date' should come before 'title'"));
assert!(!result[0].message.contains("categories"));
assert!(!result[0].message.contains("tags"));
}
#[test]
fn test_yaml_unicode_keys() {
let rule = create_enabled_rule();
let content = "---\nタイトル: Test\nあいう: Value\n日本語: Content\n---\n\n# Heading";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1);
}
#[test]
fn test_yaml_keys_with_special_characters() {
let rule = create_enabled_rule();
let content = "---\nmy-key: value1\nmy_key: value2\nmykey: value3\n---\n\n# Heading";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty());
}
#[test]
fn test_yaml_keys_with_numbers() {
let rule = create_enabled_rule();
let content = "---\nkey1: value\nkey10: value\nkey2: value\n---\n\n# Heading";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty());
}
#[test]
fn test_yaml_multiline_string_block_literal() {
let rule = create_enabled_rule();
let content =
"---\ndescription: |\n This is a\n multiline literal\ntitle: Test\nauthor: John\n---\n\n# Heading";
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("'author' should come before 'title'"));
}
#[test]
fn test_yaml_multiline_string_folded() {
let rule = create_enabled_rule();
let content = "---\ndescription: >\n This is a\n folded string\nauthor: John\n---\n\n# Heading";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1);
}
#[test]
fn test_yaml_fix_preserves_multiline_values() {
let rule = create_enabled_rule();
let content = "---\ntitle: Test\ndescription: |\n Line 1\n Line 2\n---\n\n# Heading";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
let desc_pos = fixed.find("description").unwrap();
let title_pos = fixed.find("title").unwrap();
assert!(desc_pos < title_pos);
}
#[test]
fn test_yaml_quoted_keys() {
let rule = create_enabled_rule();
let content = "---\n\"quoted-key\": value1\nunquoted: value2\n---\n\n# Heading";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty());
}
#[test]
fn test_yaml_duplicate_keys() {
let rule = create_enabled_rule();
let content = "---\ntitle: First\nauthor: John\ntitle: Second\n---\n\n# Heading";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1);
}
#[test]
fn test_toml_inline_table() {
let rule = create_enabled_rule();
let content =
"+++\nauthor = { name = \"John\", email = \"john@example.com\" }\ntitle = \"Test\"\n+++\n\n# Heading";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty());
}
#[test]
fn test_toml_array_of_tables() {
let rule = create_enabled_rule();
let content = "+++\ntitle = \"Test\"\ndate = \"2024-01-01\"\n\n[[authors]]\nname = \"John\"\n\n[[authors]]\nname = \"Jane\"\n+++\n\n# Heading";
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("'date' should come before 'title'"));
}
#[test]
fn test_json_nested_objects() {
let rule = create_enabled_rule();
let content = "{\n\"author\": {\n \"name\": \"John\",\n \"email\": \"john@example.com\"\n},\n\"title\": \"Test\"\n}\n\n# Heading";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty());
}
#[test]
fn test_json_arrays() {
let rule = create_enabled_rule();
let content = "{\n\"tags\": [\"rust\", \"markdown\"],\n\"author\": \"John\"\n}\n\n# Heading";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1);
}
#[test]
fn test_fix_preserves_content_after_frontmatter() {
let rule = create_enabled_rule();
let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading\n\nParagraph 1.\n\n- List item\n- Another item";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert!(fixed.contains("# Heading"));
assert!(fixed.contains("Paragraph 1."));
assert!(fixed.contains("- List item"));
assert!(fixed.contains("- Another item"));
}
#[test]
fn test_fix_yaml_produces_valid_yaml() {
let rule = create_enabled_rule();
let content = "---\ntitle: \"Test: A Title\"\nauthor: John Doe\ndate: 2024-01-15\n---\n\n# Heading";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
let lines: Vec<&str> = fixed.lines().collect();
let fm_end = lines.iter().skip(1).position(|l| *l == "---").unwrap() + 1;
let fm_content: String = lines[1..fm_end].join("\n");
let parsed: Result<serde_yaml::Value, _> = serde_yaml::from_str(&fm_content);
assert!(parsed.is_ok(), "Fixed YAML should be valid: {fm_content}");
}
#[test]
fn test_fix_toml_produces_valid_toml() {
let rule = create_enabled_rule();
let content = "+++\ntitle = \"Test\"\nauthor = \"John Doe\"\ndate = 2024-01-15\n+++\n\n# Heading";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
let lines: Vec<&str> = fixed.lines().collect();
let fm_end = lines.iter().skip(1).position(|l| *l == "+++").unwrap() + 1;
let fm_content: String = lines[1..fm_end].join("\n");
let parsed: Result<toml::Value, _> = toml::from_str(&fm_content);
assert!(parsed.is_ok(), "Fixed TOML should be valid: {fm_content}");
}
#[test]
fn test_fix_json_produces_valid_json() {
let rule = create_enabled_rule();
let content = "{\n\"title\": \"Test\",\n\"author\": \"John\"\n}\n\n# Heading";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
let json_end = fixed.find("\n\n").unwrap();
let json_content = &fixed[..json_end];
let parsed: Result<serde_json::Value, _> = serde_json::from_str(json_content);
assert!(parsed.is_ok(), "Fixed JSON should be valid: {json_content}");
}
#[test]
fn test_many_keys_performance() {
let rule = create_enabled_rule();
let mut keys: Vec<String> = (0..100).map(|i| format!("key{i:03}: value{i}")).collect();
keys.reverse(); let content = format!("---\n{}\n---\n\n# Heading", keys.join("\n"));
let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1);
}
#[test]
fn test_yaml_empty_value() {
let rule = create_enabled_rule();
let content = "---\ntitle:\nauthor: John\n---\n\n# Heading";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1);
}
#[test]
fn test_yaml_null_value() {
let rule = create_enabled_rule();
let content = "---\ntitle: null\nauthor: John\n---\n\n# Heading";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1);
}
#[test]
fn test_yaml_boolean_values() {
let rule = create_enabled_rule();
let content = "---\ndraft: true\nauthor: John\n---\n\n# Heading";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1);
}
#[test]
fn test_toml_boolean_values() {
let rule = create_enabled_rule();
let content = "+++\ndraft = true\nauthor = \"John\"\n+++\n\n# Heading";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1);
}
#[test]
fn test_yaml_list_at_top_level() {
let rule = create_enabled_rule();
let content = "---\ntags:\n - rust\n - markdown\nauthor: John\n---\n\n# Heading";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1);
}
#[test]
fn test_three_keys_all_orderings() {
let rule = create_enabled_rule();
let orderings = [
("a, b, c", "---\na: 1\nb: 2\nc: 3\n---\n\n# H", true), ("a, c, b", "---\na: 1\nc: 3\nb: 2\n---\n\n# H", false), ("b, a, c", "---\nb: 2\na: 1\nc: 3\n---\n\n# H", false), ("b, c, a", "---\nb: 2\nc: 3\na: 1\n---\n\n# H", false), ("c, a, b", "---\nc: 3\na: 1\nb: 2\n---\n\n# H", false), ("c, b, a", "---\nc: 3\nb: 2\na: 1\n---\n\n# H", false), ];
for (name, content, should_pass) in orderings {
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result.is_empty(),
should_pass,
"Ordering {name} should {} pass",
if should_pass { "" } else { "not" }
);
}
}
#[test]
fn test_crlf_line_endings() {
let rule = create_enabled_rule();
let content = "---\r\ntitle: Test\r\nauthor: John\r\n---\r\n\r\n# Heading";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1);
}
#[test]
fn test_json_escaped_quotes_in_keys() {
let rule = create_enabled_rule();
let content = "{\n\"normal\": \"value\",\n\"key\": \"with \\\"quotes\\\"\"\n}\n\n# Heading";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1);
}
#[test]
fn test_warning_fix_yaml_sorts_keys() {
let rule = create_enabled_rule();
let content = "---\nbbb: 123\naaa:\n - hello\n - world\n---\n\n# Heading\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let warnings = rule.check(&ctx).unwrap();
assert_eq!(warnings.len(), 1);
assert!(warnings[0].fix.is_some(), "Warning should have a fix attached for LSP");
let fix = warnings[0].fix.as_ref().unwrap();
assert_eq!(fix.range, 0..content.len(), "Fix should replace entire content");
let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
let aaa_pos = fixed.find("aaa:").expect("aaa should exist");
let bbb_pos = fixed.find("bbb:").expect("bbb should exist");
assert!(aaa_pos < bbb_pos, "aaa should come before bbb after sorting");
}
#[test]
fn test_warning_fix_preserves_yaml_list_indentation() {
let rule = create_enabled_rule();
let content = "---\nbbb: 123\naaa:\n - hello\n - world\n---\n\n# Heading\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let warnings = rule.check(&ctx).unwrap();
let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
assert!(
fixed.contains(" - hello"),
"List indentation should be preserved: {fixed}"
);
assert!(
fixed.contains(" - world"),
"List indentation should be preserved: {fixed}"
);
}
#[test]
fn test_warning_fix_preserves_nested_object_indentation() {
let rule = create_enabled_rule();
let content = "---\nzzzz: value\naaaa:\n nested_key: nested_value\n another: 123\n---\n\n# Heading\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let warnings = rule.check(&ctx).unwrap();
assert_eq!(warnings.len(), 1);
let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
let aaaa_pos = fixed.find("aaaa:").expect("aaaa should exist");
let zzzz_pos = fixed.find("zzzz:").expect("zzzz should exist");
assert!(aaaa_pos < zzzz_pos, "aaaa should come before zzzz");
assert!(
fixed.contains(" nested_key: nested_value"),
"Nested object indentation should be preserved: {fixed}"
);
assert!(
fixed.contains(" another: 123"),
"Nested object indentation should be preserved: {fixed}"
);
}
#[test]
fn test_warning_fix_preserves_deeply_nested_structure() {
let rule = create_enabled_rule();
let content = "---\nzzz: top\naaa:\n level1:\n level2:\n - item1\n - item2\n---\n\n# Content\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let warnings = rule.check(&ctx).unwrap();
let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
let aaa_pos = fixed.find("aaa:").expect("aaa should exist");
let zzz_pos = fixed.find("zzz:").expect("zzz should exist");
assert!(aaa_pos < zzz_pos, "aaa should come before zzz");
assert!(fixed.contains(" level1:"), "2-space indent should be preserved");
assert!(fixed.contains(" level2:"), "4-space indent should be preserved");
assert!(fixed.contains(" - item1"), "6-space indent should be preserved");
assert!(fixed.contains(" - item2"), "6-space indent should be preserved");
}
#[test]
fn test_warning_fix_toml_sorts_keys() {
let rule = create_enabled_rule();
let content = "+++\ntitle = \"Test\"\nauthor = \"John\"\n+++\n\n# Heading\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let warnings = rule.check(&ctx).unwrap();
assert_eq!(warnings.len(), 1);
assert!(warnings[0].fix.is_some(), "TOML warning should have a fix");
let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
let author_pos = fixed.find("author").expect("author should exist");
let title_pos = fixed.find("title").expect("title should exist");
assert!(author_pos < title_pos, "author should come before title");
}
#[test]
fn test_warning_fix_json_sorts_keys() {
let rule = create_enabled_rule();
let content = "{\n\"title\": \"Test\",\n\"author\": \"John\"\n}\n\n# Heading\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let warnings = rule.check(&ctx).unwrap();
assert_eq!(warnings.len(), 1);
assert!(warnings[0].fix.is_some(), "JSON warning should have a fix");
let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
let author_pos = fixed.find("author").expect("author should exist");
let title_pos = fixed.find("title").expect("title should exist");
assert!(author_pos < title_pos, "author should come before title");
}
#[test]
fn test_warning_fix_no_fix_when_comments_present() {
let rule = create_enabled_rule();
let content = "---\ntitle: Test\n# This is a comment\nauthor: John\n---\n\n# Heading\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let warnings = rule.check(&ctx).unwrap();
assert_eq!(warnings.len(), 1);
assert!(
warnings[0].fix.is_none(),
"Warning should NOT have a fix when comments are present"
);
assert!(
warnings[0].message.contains("auto-fix unavailable"),
"Message should indicate auto-fix is unavailable"
);
}
#[test]
fn test_warning_fix_preserves_content_after_frontmatter() {
let rule = create_enabled_rule();
let content = "---\nzzz: last\naaa: first\n---\n\n# Heading\n\nParagraph with content.\n\n- List item\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let warnings = rule.check(&ctx).unwrap();
let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
assert!(fixed.contains("# Heading"), "Heading should be preserved");
assert!(
fixed.contains("Paragraph with content."),
"Paragraph should be preserved"
);
assert!(fixed.contains("- List item"), "List item should be preserved");
}
#[test]
fn test_warning_fix_idempotent() {
let rule = create_enabled_rule();
let content = "---\nbbb: 2\naaa: 1\n---\n\n# Heading\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let warnings = rule.check(&ctx).unwrap();
let fixed_once = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
let ctx2 = LintContext::new(&fixed_once, crate::config::MarkdownFlavor::Standard, None);
let warnings2 = rule.check(&ctx2).unwrap();
assert!(
warnings2.is_empty(),
"After fixing, no more warnings should be produced"
);
}
#[test]
fn test_warning_fix_preserves_multiline_block_literal() {
let rule = create_enabled_rule();
let content = "---\nzzz: simple\naaa: |\n Line 1 of block\n Line 2 of block\n---\n\n# Heading\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let warnings = rule.check(&ctx).unwrap();
let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
assert!(fixed.contains("aaa: |"), "Block literal marker should be preserved");
assert!(
fixed.contains(" Line 1 of block"),
"Block literal line 1 should be preserved with indent"
);
assert!(
fixed.contains(" Line 2 of block"),
"Block literal line 2 should be preserved with indent"
);
}
#[test]
fn test_warning_fix_preserves_folded_string() {
let rule = create_enabled_rule();
let content = "---\nzzz: simple\naaa: >\n Folded line 1\n Folded line 2\n---\n\n# Content\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let warnings = rule.check(&ctx).unwrap();
let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
assert!(fixed.contains("aaa: >"), "Folded string marker should be preserved");
assert!(
fixed.contains(" Folded line 1"),
"Folded line 1 should be preserved with indent"
);
assert!(
fixed.contains(" Folded line 2"),
"Folded line 2 should be preserved with indent"
);
}
#[test]
fn test_warning_fix_preserves_4_space_indentation() {
let rule = create_enabled_rule();
let content = "---\nzzz: value\naaa:\n nested: with_4_spaces\n another: value\n---\n\n# Heading\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let warnings = rule.check(&ctx).unwrap();
let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
assert!(
fixed.contains(" nested: with_4_spaces"),
"4-space indentation should be preserved: {fixed}"
);
assert!(
fixed.contains(" another: value"),
"4-space indentation should be preserved: {fixed}"
);
}
#[test]
fn test_warning_fix_preserves_tab_indentation() {
let rule = create_enabled_rule();
let content = "---\nzzz: value\naaa:\n\tnested: with_tab\n\tanother: value\n---\n\n# Heading\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let warnings = rule.check(&ctx).unwrap();
let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
assert!(
fixed.contains("\tnested: with_tab"),
"Tab indentation should be preserved: {fixed}"
);
assert!(
fixed.contains("\tanother: value"),
"Tab indentation should be preserved: {fixed}"
);
}
#[test]
fn test_warning_fix_preserves_inline_list() {
let rule = create_enabled_rule();
let content = "---\nzzz: value\naaa: [one, two, three]\n---\n\n# Heading\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let warnings = rule.check(&ctx).unwrap();
let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
assert!(
fixed.contains("aaa: [one, two, three]"),
"Inline list should be preserved exactly: {fixed}"
);
}
#[test]
fn test_warning_fix_preserves_quoted_strings() {
let rule = create_enabled_rule();
let content = "---\nzzz: simple\naaa: \"value with: colon\"\nbbb: 'single quotes'\n---\n\n# Heading\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let warnings = rule.check(&ctx).unwrap();
let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
assert!(
fixed.contains("aaa: \"value with: colon\""),
"Double-quoted string should be preserved: {fixed}"
);
assert!(
fixed.contains("bbb: 'single quotes'"),
"Single-quoted string should be preserved: {fixed}"
);
}
#[test]
fn test_yaml_custom_key_order_sorted() {
let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
let content = "---\ntitle: Test\ndate: 2024-01-01\nauthor: John\n---\n\n# Heading";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty());
}
#[test]
fn test_yaml_custom_key_order_unsorted() {
let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
let content = "---\ntitle: Test\nauthor: John\ndate: 2024-01-01\n---\n\n# Heading";
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("'date' should come before 'author'"));
}
#[test]
fn test_yaml_custom_key_order_unlisted_keys_alphabetical() {
let rule = create_rule_with_key_order(vec!["title"]);
let content = "---\ntitle: Test\nauthor: John\ndate: 2024-01-01\n---\n\n# Heading";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty());
}
#[test]
fn test_yaml_custom_key_order_unlisted_keys_unsorted() {
let rule = create_rule_with_key_order(vec!["title"]);
let content = "---\ntitle: Test\nzebra: Zoo\nauthor: John\n---\n\n# Heading";
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("'author' should come before 'zebra'"));
}
#[test]
fn test_yaml_custom_key_order_fix() {
let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
let content = "---\nauthor: John\ndate: 2024-01-01\ntitle: Test\n---\n\n# Heading";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
let title_pos = fixed.find("title:").unwrap();
let date_pos = fixed.find("date:").unwrap();
let author_pos = fixed.find("author:").unwrap();
assert!(
title_pos < date_pos && date_pos < author_pos,
"Fixed YAML should have keys in custom order: title, date, author. Got:\n{fixed}"
);
}
#[test]
fn test_yaml_custom_key_order_fix_with_unlisted() {
let rule = create_rule_with_key_order(vec!["title", "author"]);
let content = "---\nzebra: Zoo\nauthor: John\ntitle: Test\naardvark: Ant\n---\n\n# Heading";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
let title_pos = fixed.find("title:").unwrap();
let author_pos = fixed.find("author:").unwrap();
let aardvark_pos = fixed.find("aardvark:").unwrap();
let zebra_pos = fixed.find("zebra:").unwrap();
assert!(
title_pos < author_pos && author_pos < aardvark_pos && aardvark_pos < zebra_pos,
"Fixed YAML should have specified keys first, then unlisted alphabetically. Got:\n{fixed}"
);
}
#[test]
fn test_toml_custom_key_order_sorted() {
let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
let content = "+++\ntitle = \"Test\"\ndate = \"2024-01-01\"\nauthor = \"John\"\n+++\n\n# Heading";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty());
}
#[test]
fn test_toml_custom_key_order_unsorted() {
let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
let content = "+++\nauthor = \"John\"\ntitle = \"Test\"\ndate = \"2024-01-01\"\n+++\n\n# Heading";
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("TOML"));
}
#[test]
fn test_json_custom_key_order_sorted() {
let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
let content = "{\n \"title\": \"Test\",\n \"date\": \"2024-01-01\",\n \"author\": \"John\"\n}\n\n# Heading";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty());
}
#[test]
fn test_json_custom_key_order_unsorted() {
let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
let content = "{\n \"author\": \"John\",\n \"title\": \"Test\",\n \"date\": \"2024-01-01\"\n}\n\n# Heading";
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("JSON"));
}
#[test]
fn test_key_order_case_insensitive_match() {
let rule = create_rule_with_key_order(vec!["Title", "Date", "Author"]);
let content = "---\ntitle: Test\ndate: 2024-01-01\nauthor: John\n---\n\n# Heading";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty());
}
#[test]
fn test_key_order_partial_match() {
let rule = create_rule_with_key_order(vec!["title"]);
let content = "---\ntitle: Test\ndate: 2024-01-01\nauthor: John\n---\n\n# Heading";
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("'author' should come before 'date'"));
}
#[test]
fn test_key_order_empty_array_falls_back_to_alphabetical() {
let rule = MD072FrontmatterKeySort::from_config_struct(MD072Config {
enabled: true,
key_order: Some(vec![]),
..Default::default()
});
let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading";
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("'author' should come before 'title'"));
}
#[test]
fn test_key_order_single_key() {
let rule = create_rule_with_key_order(vec!["title"]);
let content = "---\ntitle: Test\n---\n\n# Heading";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty());
}
#[test]
fn test_key_order_all_keys_specified() {
let rule = create_rule_with_key_order(vec!["title", "author", "date"]);
let content = "---\ntitle: Test\nauthor: John\ndate: 2024-01-01\n---\n\n# Heading";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty());
}
#[test]
fn test_key_order_no_keys_match() {
let rule = create_rule_with_key_order(vec!["foo", "bar", "baz"]);
let content = "---\nauthor: John\ndate: 2024-01-01\ntitle: Test\n---\n\n# Heading";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty());
}
#[test]
fn test_key_order_no_keys_match_unsorted() {
let rule = create_rule_with_key_order(vec!["foo", "bar", "baz"]);
let content = "---\ntitle: Test\ndate: 2024-01-01\nauthor: John\n---\n\n# Heading";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1);
}
#[test]
fn test_key_order_duplicate_keys_in_config() {
let rule = MD072FrontmatterKeySort::from_config_struct(MD072Config {
enabled: true,
key_order: Some(vec![
"title".to_string(),
"author".to_string(),
"title".to_string(), ]),
..Default::default()
});
let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty());
}
#[test]
fn test_key_order_with_comments_still_skips_fix() {
let rule = create_rule_with_key_order(vec!["title", "author"]);
let content = "---\n# This is a comment\nauthor: John\ntitle: Test\n---\n\n# Heading";
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("auto-fix unavailable"));
assert!(result[0].fix.is_none());
}
#[test]
fn test_toml_custom_key_order_fix() {
let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
let content = "+++\nauthor = \"John\"\ndate = \"2024-01-01\"\ntitle = \"Test\"\n+++\n\n# Heading";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
let title_pos = fixed.find("title").unwrap();
let date_pos = fixed.find("date").unwrap();
let author_pos = fixed.find("author").unwrap();
assert!(
title_pos < date_pos && date_pos < author_pos,
"Fixed TOML should have keys in custom order. Got:\n{fixed}"
);
}
#[test]
fn test_json_custom_key_order_fix() {
let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
let content = "{\n \"author\": \"John\",\n \"date\": \"2024-01-01\",\n \"title\": \"Test\"\n}\n\n# Heading";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
let title_pos = fixed.find("\"title\"").unwrap();
let date_pos = fixed.find("\"date\"").unwrap();
let author_pos = fixed.find("\"author\"").unwrap();
assert!(
title_pos < date_pos && date_pos < author_pos,
"Fixed JSON should have keys in custom order. Got:\n{fixed}"
);
}
#[test]
fn test_key_order_unicode_keys() {
let rule = MD072FrontmatterKeySort::from_config_struct(MD072Config {
enabled: true,
key_order: Some(vec!["タイトル".to_string(), "著者".to_string()]),
..Default::default()
});
let content = "---\nタイトル: テスト\n著者: 山田太郎\n---\n\n# Heading";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty());
}
#[test]
fn test_key_order_mixed_specified_and_unlisted_boundary() {
let rule = create_rule_with_key_order(vec!["z_last_specified"]);
let content = "---\nz_last_specified: value\na_first_unlisted: value\n---\n\n# Heading";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty());
}
#[test]
fn test_key_order_fix_preserves_values() {
let rule = create_rule_with_key_order(vec!["title", "tags"]);
let content = "---\ntags:\n - rust\n - markdown\ntitle: Test\n---\n\n# Heading";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
let title_pos = fixed.find("title:").unwrap();
let tags_pos = fixed.find("tags:").unwrap();
assert!(title_pos < tags_pos, "title should come before tags");
assert!(fixed.contains("- rust"), "List items should be preserved");
assert!(fixed.contains("- markdown"), "List items should be preserved");
}
#[test]
fn test_key_order_idempotent_fix() {
let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
let content = "---\nauthor: John\ndate: 2024-01-01\ntitle: Test\n---\n\n# Heading";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed_once = rule.fix(&ctx).unwrap();
let ctx2 = LintContext::new(&fixed_once, crate::config::MarkdownFlavor::Standard, None);
let fixed_twice = rule.fix(&ctx2).unwrap();
assert_eq!(fixed_once, fixed_twice, "Fix should be idempotent");
}
#[test]
fn test_key_order_respects_later_position_over_alphabetical() {
let rule = create_rule_with_key_order(vec!["zebra", "aardvark"]);
let content = "---\nzebra: Zoo\naardvark: Ant\n---\n\n# Heading";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty());
}
#[test]
fn test_json_braces_in_string_values_extracts_all_keys() {
let rule = create_enabled_rule();
let content = "{\n\"author\": \"Someone\",\n\"description\": \"Use { to open\",\n\"tags\": [\"a\"],\n\"title\": \"My Post\"\n}\n\nContent here.\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"All keys should be extracted and recognized as sorted. Got: {result:?}"
);
}
#[test]
fn test_json_braces_in_string_key_after_brace_value_detected() {
let rule = create_enabled_rule();
let content = "{\n\"description\": \"Use { to open\",\n\"author\": \"Someone\"\n}\n\nContent.\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result.len(),
1,
"Should detect unsorted keys after brace-containing string value"
);
assert!(
result[0].message.contains("'author' should come before 'description'"),
"Should report author before description. Got: {}",
result[0].message
);
}
#[test]
fn test_json_brackets_in_string_values() {
let rule = create_enabled_rule();
let content = "{\n\"description\": \"My [Post]\",\n\"author\": \"Someone\"\n}\n\nContent.\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result.len(),
1,
"Should detect unsorted keys despite brackets in string values"
);
assert!(
result[0].message.contains("'author' should come before 'description'"),
"Got: {}",
result[0].message
);
}
#[test]
fn test_json_escaped_quotes_in_values() {
let rule = create_enabled_rule();
let content = "{\n\"title\": \"He said \\\"hello {world}\\\"\",\n\"author\": \"Someone\"\n}\n\nContent.\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1, "Should handle escaped quotes with braces in values");
assert!(
result[0].message.contains("'author' should come before 'title'"),
"Got: {}",
result[0].message
);
}
#[test]
fn test_json_multiple_braces_in_string() {
let rule = create_enabled_rule();
let content = "{\n\"pattern\": \"{{{}}\",\n\"author\": \"Someone\"\n}\n\nContent.\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1, "Should handle multiple braces in string values");
assert!(
result[0].message.contains("'author' should come before 'pattern'"),
"Got: {}",
result[0].message
);
}
#[test]
fn test_key_order_detects_wrong_custom_order() {
let rule = create_rule_with_key_order(vec!["zebra", "aardvark"]);
let content = "---\naardvark: Ant\nzebra: Zoo\n---\n\n# Heading";
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("'zebra' should come before 'aardvark'"));
}
#[test]
fn test_required_keys_yaml_missing_key_warns_without_fix() {
let rule = create_rule_with_required_keys(vec!["title", "date"]);
let content = "---\ntitle: Test\n---\n\n# Heading";
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("missing required key 'date'"));
assert!(result[0].message.contains("YAML"));
assert!(result[0].fix.is_none(), "missing keys must not be auto-fixable");
assert_eq!(result[0].line, 1);
assert_eq!(result[0].column, 1);
assert_eq!(result[0].end_column, 4);
}
#[test]
fn test_required_keys_all_present_no_warning() {
let rule = create_rule_with_required_keys(vec!["author", "title"]);
let content = "---\nauthor: John\ntitle: Test\n---\n\n# Heading";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty());
}
#[test]
fn test_required_keys_one_warning_per_missing_key() {
let rule = create_rule_with_required_keys(vec!["title", "date", "author"]);
let content = "---\ntags: [a, b]\n---\n\n# Heading";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 3);
let messages: Vec<&str> = result.iter().map(|w| w.message.as_str()).collect();
assert!(messages.iter().any(|m| m.contains("'title'")));
assert!(messages.iter().any(|m| m.contains("'date'")));
assert!(messages.iter().any(|m| m.contains("'author'")));
}
#[test]
fn test_required_keys_case_insensitive_match() {
let rule = create_rule_with_required_keys(vec!["Title"]);
let content = "---\ntitle: Test\n---\n\n# Heading";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty());
}
#[test]
fn test_required_keys_missing_and_unsorted_both_reported() {
let rule = MD072FrontmatterKeySort::from_config_struct(MD072Config {
enabled: true,
required_keys: vec!["date".to_string()],
..Default::default()
});
let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 2);
assert!(result[0].message.contains("missing required key 'date'"));
assert!(result[1].message.contains("'author' should come before 'title'"));
}
#[test]
fn test_required_keys_toml_missing_key() {
let rule = create_rule_with_required_keys(vec!["title", "date"]);
let content = "+++\ntitle = \"Test\"\n+++\n\n# Heading";
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("TOML frontmatter is missing required key 'date'")
);
assert!(result[0].fix.is_none());
}
#[test]
fn test_required_keys_json_missing_key() {
let rule = create_rule_with_required_keys(vec!["title", "date"]);
let content = "{\n\"title\": \"Test\"\n}\n\n# Heading";
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("JSON frontmatter is missing required key 'date'")
);
assert_eq!(result[0].end_column, 2);
}
#[test]
fn test_required_keys_no_frontmatter_no_warning() {
let rule = create_rule_with_required_keys(vec!["title"]);
let content = "# Heading\n\nContent.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty());
}
#[test]
fn test_required_keys_empty_frontmatter_warns() {
let rule = create_rule_with_required_keys(vec!["title", "date"]);
let content = "---\n---\n\n# Heading";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 2);
assert!(result.iter().all(|w| w.message.contains("missing required key")));
}
#[test]
fn test_required_keys_nested_key_does_not_satisfy() {
let rule = create_rule_with_required_keys(vec!["title"]);
let content = "---\nmeta:\n title: Nested\n---\n\n# Heading";
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("missing required key 'title'"));
}
#[test]
fn test_required_keys_quoted_yaml_key_satisfies() {
let rule = create_rule_with_required_keys(vec!["title"]);
let content = "---\n\"title\": Test\n---\n\n# Heading";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty());
}
#[test]
fn test_required_keys_fix_does_not_insert_keys() {
let rule = create_rule_with_required_keys(vec!["date"]);
let content = "---\nauthor: John\ntitle: Test\n---\n\n# Heading";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, content);
}
#[test]
fn test_required_keys_with_key_order_subset() {
let rule = MD072FrontmatterKeySort::from_config_struct(MD072Config {
enabled: true,
key_order: Some(vec![
"title".to_string(),
"date".to_string(),
"author".to_string(),
"tags".to_string(),
]),
required_keys: vec!["title".to_string(), "date".to_string()],
});
let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading";
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("missing required key 'date'"));
let content = "---\ntitle: Test\ndate: 2024-01-01\ntags: [a]\n---\n\n# Heading";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty());
}
#[test]
fn test_required_keys_unsorted_fix_still_applies_without_inserting() {
let rule = create_rule_with_required_keys(vec!["date"]);
let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
let author_pos = fixed.find("author:").unwrap();
let title_pos = fixed.find("title:").unwrap();
assert!(author_pos < title_pos, "sort fix must still apply");
assert!(!fixed.contains("date"), "fix must not insert the missing key");
let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx2).unwrap();
assert_eq!(result.len(), 1);
assert!(result[0].message.contains("missing required key 'date'"));
}
#[test]
fn test_required_keys_warning_spans_the_frontmatter_block() {
let rule = create_rule_with_required_keys(vec!["date"]);
let content = "---\ntitle: Test\n---\n\n# Heading";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].line, 1);
assert_eq!(result[0].column, 1);
assert_eq!(result[0].end_line, 3, "span must reach the closing fence line");
assert_eq!(result[0].end_column, 4);
}
#[test]
fn test_required_keys_suppressed_by_inline_disable_in_frontmatter() {
let rule = create_rule_with_required_keys(vec!["date"]);
let content = "---\n# <!-- rumdl-disable MD072 -->\ntitle: Test\n---\n\n# Heading\n";
let warnings = crate::lint(
content,
&[Box::new(rule) as Box<dyn Rule>],
false,
crate::config::MarkdownFlavor::Standard,
None,
None,
)
.unwrap();
assert!(
warnings.is_empty(),
"inline disable inside the frontmatter must suppress missing-key warnings, got: {warnings:?}"
);
}
#[test]
fn test_required_keys_reported_through_lint_without_disable() {
let rule = create_rule_with_required_keys(vec!["date"]);
let content = "---\ntitle: Test\n---\n\n# Heading\n";
let warnings = crate::lint(
content,
&[Box::new(rule) as Box<dyn Rule>],
false,
crate::config::MarkdownFlavor::Standard,
None,
None,
)
.unwrap();
assert_eq!(warnings.len(), 1);
assert!(warnings[0].message.contains("missing required key 'date'"));
}
#[test]
fn test_required_keys_quoted_toml_key_satisfies() {
let rule = create_rule_with_required_keys(vec!["title", "date"]);
let content = "+++\n\"date\" = \"2024-01-01\"\n'title' = \"Test\"\n+++\n\n# Heading";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"quoted TOML keys must satisfy required-keys, got: {result:?}"
);
}
#[test]
fn test_toml_quoted_keys_sort_by_content() {
let rule = create_enabled_rule();
let content = "+++\n\"zebra\" = 1\napple = 2\n+++\n\n# Heading";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1, "quoted TOML key out of order must be flagged");
assert!(result[0].message.contains("'apple' should come before 'zebra'"));
}
#[test]
fn test_toml_quoted_key_warning_span_covers_quotes() {
let rule = create_enabled_rule();
let content = "+++\nbanana = 1\n\"apple\" = 2\n+++\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1);
let w = &result[0];
assert_eq!(w.line, 3);
assert_eq!(w.column, 1);
assert_eq!(w.end_column, 8, "diagnostic span must cover the quoted key");
}
#[test]
fn test_required_keys_json_multiple_keys_on_one_line() {
let rule = create_rule_with_required_keys(vec!["title", "date"]);
let content = "{\n\"title\": \"Test\", \"date\": \"2024-01-01\"\n}\n\n# Heading";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"all keys on one JSON line must satisfy required-keys, got: {result:?}"
);
}
#[test]
fn test_required_keys_json_multiple_keys_on_one_line_missing_still_reported() {
let rule = create_rule_with_required_keys(vec!["title", "date", "author"]);
let content = "{\n\"title\": \"Test\", \"date\": \"2024-01-01\"\n}\n\n# Heading";
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("missing required key 'author'"));
}
#[test]
fn test_required_keys_json_invalid_falls_back_to_line_based_keys() {
let rule = create_rule_with_required_keys(vec!["title"]);
let content = "{\n\"title\": unquoted-invalid\n}\n\n# Heading";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"invalid JSON must fall back to line-based key extraction, got: {result:?}"
);
}
#[test]
fn test_required_keys_toml_table_header_satisfies() {
let rule = create_rule_with_required_keys(vec!["title", "taxonomies"]);
let content = "+++\ntitle = \"Test\"\n\n[taxonomies]\ntags = [\"a\"]\n+++\n\n# Heading";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"a TOML table header must satisfy required-keys, got: {result:?}"
);
}
#[test]
fn test_required_keys_toml_array_of_tables_satisfies() {
let rule = create_rule_with_required_keys(vec!["authors"]);
let content = "+++\ntitle = \"Test\"\n\n[[authors]]\nname = \"John\"\n+++\n\n# Heading";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"a TOML array-of-tables header must satisfy required-keys, got: {result:?}"
);
}
#[test]
fn test_required_keys_toml_dotted_table_header_satisfies_root() {
let rule = create_rule_with_required_keys(vec!["params"]);
let content = "+++\ntitle = \"Test\"\n\n[params.seo]\nnoindex = true\n+++\n\n# Heading";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"a dotted TOML table header must satisfy its root key, got: {result:?}"
);
}
#[test]
fn test_required_keys_toml_missing_despite_other_tables() {
let rule = create_rule_with_required_keys(vec!["date"]);
let content = "+++\ntitle = \"Test\"\n\n[taxonomies]\ntags = [\"a\"]\n+++\n\n# Heading";
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("missing required key 'date'"));
}
#[test]
fn test_required_keys_toml_dotted_assignment_satisfies_root() {
let rule = create_rule_with_required_keys(vec!["params"]);
let content = "+++\nparams.seo = true\ntitle = \"Test\"\n+++\n\n# Heading";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"a dotted TOML assignment must satisfy its root key, got: {result:?}"
);
}
#[test]
fn test_required_keys_toml_quoted_dotted_key_is_atomic() {
let rule = create_rule_with_required_keys(vec!["a.b"]);
let content = "+++\n\"a.b\" = 1\n+++\n\n# Heading";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"quoted dotted key must match literally, got: {result:?}"
);
let rule = create_rule_with_required_keys(vec!["a"]);
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1, "quoted dotted key must NOT satisfy its first segment");
assert!(result[0].message.contains("missing required key 'a'"));
}
#[test]
fn test_required_keys_toml_table_header_with_inline_comment() {
let rule = create_rule_with_required_keys(vec!["taxonomies"]);
let content = "+++\ntitle = \"Test\"\n\n[taxonomies] # used by Hugo\ntags = [\"a\"]\n+++\n\n# Heading";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"a table header with an inline comment must satisfy required-keys, got: {result:?}"
);
}
#[test]
fn test_required_keys_toml_assignment_inside_table_does_not_satisfy() {
let rule = create_rule_with_required_keys(vec!["date"]);
let content = "+++\ntitle = \"Test\"\n\n[params]\ndate = \"2024-01-01\"\n+++\n\n# Heading";
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("missing required key 'date'"));
}
#[test]
fn test_required_keys_yaml_quoted_key_with_colon_satisfies() {
let rule = create_rule_with_required_keys(vec!["og:title"]);
let content = "---\n\"og:title\": My post\n---\n\n# Heading";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"a quoted YAML key containing a colon must satisfy required-keys, got: {result:?}"
);
}
#[test]
fn test_yaml_quoted_key_with_colon_sorts_by_full_content() {
let rule = create_enabled_rule();
let content = "---\n\"og:title\": My post\nalpha: 1\n---\n\n# Heading";
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("'alpha' should come before 'og:title'"),
"sorting must use the full quoted key, got: {}",
result[0].message
);
}
#[test]
fn test_required_keys_toml_quoted_key_with_equals_satisfies() {
let rule = create_rule_with_required_keys(vec!["a=b"]);
let content = "+++\n\"a=b\" = 1\n+++\n\n# Heading";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"a quoted TOML key containing '=' must satisfy required-keys, got: {result:?}"
);
}
}