use crate::types::LineLength;
use globset::{Glob, GlobBuilder, GlobMatcher, GlobSet, GlobSetBuilder};
use indexmap::IndexMap;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::collections::{HashMap, HashSet};
use std::fs;
use std::io;
use std::path::Path;
use std::sync::{Arc, OnceLock};
use super::flavor::{MarkdownFlavor, normalize_key};
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, schemars::JsonSchema)]
pub struct RuleConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub severity: Option<crate::rule::Severity>,
#[serde(flatten)]
#[schemars(schema_with = "arbitrary_value_schema")]
pub values: BTreeMap<String, toml::Value>,
}
fn arbitrary_value_schema(_gen: &mut schemars::SchemaGenerator) -> schemars::Schema {
schemars::json_schema!({
"type": "object",
"additionalProperties": true
})
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, schemars::JsonSchema)]
#[schemars(
description = "rumdl configuration for linting Markdown files. Rules can be configured individually using [MD###] sections with rule-specific options."
)]
pub struct Config {
#[serde(default)]
pub global: GlobalConfig,
#[serde(default, rename = "per-file-ignores")]
pub per_file_ignores: HashMap<String, Vec<String>>,
#[serde(default, rename = "per-file-flavor")]
#[schemars(with = "HashMap<String, MarkdownFlavor>")]
pub per_file_flavor: IndexMap<String, MarkdownFlavor>,
#[serde(default, rename = "code-block-tools")]
pub code_block_tools: crate::code_block_tools::CodeBlockToolsConfig,
#[serde(flatten)]
pub rules: BTreeMap<String, RuleConfig>,
#[serde(skip)]
pub project_root: Option<std::path::PathBuf>,
#[serde(skip)]
#[schemars(skip)]
pub(super) per_file_ignores_cache: Arc<OnceLock<PerFileIgnoreCache>>,
#[serde(skip)]
#[schemars(skip)]
pub(super) per_file_flavor_cache: Arc<OnceLock<PerFileFlavorCache>>,
}
impl PartialEq for Config {
fn eq(&self, other: &Self) -> bool {
self.global == other.global
&& self.per_file_ignores == other.per_file_ignores
&& self.per_file_flavor == other.per_file_flavor
&& self.code_block_tools == other.code_block_tools
&& self.rules == other.rules
&& self.project_root == other.project_root
}
}
#[derive(Debug)]
pub(super) struct PerFileIgnoreCache {
globset: GlobSet,
rules: Vec<Vec<String>>,
}
#[derive(Debug)]
pub(super) struct PerFileFlavorCache {
matchers: Vec<(GlobMatcher, MarkdownFlavor)>,
}
impl Config {
pub fn is_mkdocs_flavor(&self) -> bool {
self.global.flavor == MarkdownFlavor::MkDocs
}
pub fn markdown_flavor(&self) -> MarkdownFlavor {
self.global.flavor
}
pub fn is_mkdocs_project(&self) -> bool {
self.is_mkdocs_flavor()
}
pub fn apply_per_rule_enabled(&mut self) {
let mut to_enable: Vec<String> = Vec::new();
let mut to_disable: Vec<String> = Vec::new();
for (name, cfg) in &self.rules {
match cfg.values.get("enabled") {
Some(toml::Value::Boolean(true)) => {
to_enable.push(name.clone());
}
Some(toml::Value::Boolean(false)) => {
to_disable.push(name.clone());
}
_ => {}
}
}
for name in to_enable {
if !self.global.extend_enable.contains(&name) {
self.global.extend_enable.push(name.clone());
}
self.global.disable.retain(|n| n != &name);
self.global.extend_disable.retain(|n| n != &name);
}
for name in to_disable {
if !self.global.disable.contains(&name) {
self.global.disable.push(name.clone());
}
self.global.extend_enable.retain(|n| n != &name);
}
}
pub fn get_rule_severity(&self, rule_name: &str) -> Option<crate::rule::Severity> {
self.rules.get(rule_name).and_then(|r| r.severity)
}
pub fn get_ignored_rules_for_file(&self, file_path: &Path) -> HashSet<String> {
let mut ignored_rules = HashSet::new();
if self.per_file_ignores.is_empty() {
return ignored_rules;
}
let path_for_matching: std::borrow::Cow<'_, Path> = if let Some(ref root) = self.project_root {
if let Ok(canonical_path) = file_path.canonicalize() {
if let Ok(canonical_root) = root.canonicalize() {
if let Ok(relative) = canonical_path.strip_prefix(&canonical_root) {
std::borrow::Cow::Owned(relative.to_path_buf())
} else {
std::borrow::Cow::Borrowed(file_path)
}
} else {
std::borrow::Cow::Borrowed(file_path)
}
} else {
std::borrow::Cow::Borrowed(file_path)
}
} else {
std::borrow::Cow::Borrowed(file_path)
};
let cache = self
.per_file_ignores_cache
.get_or_init(|| PerFileIgnoreCache::new(&self.per_file_ignores));
for match_idx in cache.globset.matches(path_for_matching.as_ref()) {
if let Some(rules) = cache.rules.get(match_idx) {
for rule in rules.iter() {
ignored_rules.insert(rule.clone());
}
}
}
ignored_rules
}
pub fn get_flavor_for_file(&self, file_path: &Path) -> MarkdownFlavor {
if self.per_file_flavor.is_empty() {
return self.resolve_flavor_fallback(file_path);
}
let path_for_matching: std::borrow::Cow<'_, Path> = if let Some(ref root) = self.project_root {
if let Ok(canonical_path) = file_path.canonicalize() {
if let Ok(canonical_root) = root.canonicalize() {
if let Ok(relative) = canonical_path.strip_prefix(&canonical_root) {
std::borrow::Cow::Owned(relative.to_path_buf())
} else {
std::borrow::Cow::Borrowed(file_path)
}
} else {
std::borrow::Cow::Borrowed(file_path)
}
} else {
std::borrow::Cow::Borrowed(file_path)
}
} else {
std::borrow::Cow::Borrowed(file_path)
};
let cache = self
.per_file_flavor_cache
.get_or_init(|| PerFileFlavorCache::new(&self.per_file_flavor));
for (matcher, flavor) in &cache.matchers {
if matcher.is_match(path_for_matching.as_ref()) {
return *flavor;
}
}
self.resolve_flavor_fallback(file_path)
}
fn resolve_flavor_fallback(&self, file_path: &Path) -> MarkdownFlavor {
if self.global.flavor != MarkdownFlavor::Standard {
return self.global.flavor;
}
MarkdownFlavor::from_path(file_path)
}
pub fn merge_with_inline_config(&self, inline_config: &crate::inline_config::InlineConfig) -> Self {
let overrides = inline_config.get_all_rule_configs();
if overrides.is_empty() {
return self.clone();
}
let mut merged = self.clone();
for (rule_name, json_override) in overrides {
let rule_config = merged.rules.entry(rule_name.clone()).or_default();
if let Some(obj) = json_override.as_object() {
for (key, value) in obj {
let normalized_key = key.replace('_', "-");
if let Some(toml_value) = json_to_toml(value) {
rule_config.values.insert(normalized_key, toml_value);
}
}
}
}
merged
}
}
pub(super) fn json_to_toml(json: &serde_json::Value) -> Option<toml::Value> {
match json {
serde_json::Value::Null => None,
serde_json::Value::Bool(b) => Some(toml::Value::Boolean(*b)),
serde_json::Value::Number(n) => n
.as_i64()
.map(toml::Value::Integer)
.or_else(|| n.as_f64().map(toml::Value::Float)),
serde_json::Value::String(s) => Some(toml::Value::String(s.clone())),
serde_json::Value::Array(arr) => {
let toml_arr: Vec<toml::Value> = arr.iter().filter_map(json_to_toml).collect();
Some(toml::Value::Array(toml_arr))
}
serde_json::Value::Object(obj) => {
let mut table = toml::map::Map::new();
for (k, v) in obj {
if let Some(tv) = json_to_toml(v) {
table.insert(k.clone(), tv);
}
}
Some(toml::Value::Table(table))
}
}
}
impl PerFileIgnoreCache {
fn new(per_file_ignores: &HashMap<String, Vec<String>>) -> Self {
let mut builder = GlobSetBuilder::new();
let mut rules = Vec::new();
for (pattern, rules_list) in per_file_ignores {
if let Ok(glob) = Glob::new(pattern) {
builder.add(glob);
rules.push(rules_list.iter().map(|rule| normalize_key(rule)).collect());
} else {
log::warn!("Invalid glob pattern in per-file-ignores: {pattern}");
}
}
let globset = builder.build().unwrap_or_else(|e| {
log::error!("Failed to build globset for per-file-ignores: {e}");
GlobSetBuilder::new().build().unwrap()
});
Self { globset, rules }
}
}
impl PerFileFlavorCache {
fn new(per_file_flavor: &IndexMap<String, MarkdownFlavor>) -> Self {
let mut matchers = Vec::new();
for (pattern, flavor) in per_file_flavor {
if let Ok(glob) = GlobBuilder::new(pattern).literal_separator(true).build() {
matchers.push((glob.compile_matcher(), *flavor));
} else {
log::warn!("Invalid glob pattern in per-file-flavor: {pattern}");
}
}
Self { matchers }
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, schemars::JsonSchema)]
#[serde(default, rename_all = "kebab-case")]
pub struct GlobalConfig {
#[serde(default)]
pub enable: Vec<String>,
#[serde(default)]
pub disable: Vec<String>,
#[serde(default)]
pub exclude: Vec<String>,
#[serde(default)]
pub include: Vec<String>,
#[serde(default = "default_respect_gitignore", alias = "respect_gitignore")]
pub respect_gitignore: bool,
#[serde(default, alias = "line_length")]
pub line_length: LineLength,
#[serde(skip_serializing_if = "Option::is_none", alias = "output_format")]
pub output_format: Option<String>,
#[serde(default)]
pub fixable: Vec<String>,
#[serde(default)]
pub unfixable: Vec<String>,
#[serde(default)]
pub flavor: MarkdownFlavor,
#[serde(default, alias = "force_exclude")]
#[deprecated(since = "0.0.156", note = "Exclude patterns are now always respected")]
pub force_exclude: bool,
#[serde(default, alias = "cache_dir", skip_serializing_if = "Option::is_none")]
pub cache_dir: Option<String>,
#[serde(default = "default_true")]
pub cache: bool,
#[serde(default, alias = "extend_enable")]
pub extend_enable: Vec<String>,
#[serde(default, alias = "extend_disable")]
pub extend_disable: Vec<String>,
#[serde(skip)]
pub enable_is_explicit: bool,
}
fn default_respect_gitignore() -> bool {
true
}
fn default_true() -> bool {
true
}
impl Default for GlobalConfig {
#[allow(deprecated)]
fn default() -> Self {
Self {
enable: Vec::new(),
disable: Vec::new(),
exclude: Vec::new(),
include: Vec::new(),
respect_gitignore: true,
line_length: LineLength::default(),
output_format: None,
fixable: Vec::new(),
unfixable: Vec::new(),
flavor: MarkdownFlavor::default(),
force_exclude: false,
cache_dir: None,
cache: true,
extend_enable: Vec::new(),
extend_disable: Vec::new(),
enable_is_explicit: false,
}
}
}
pub(crate) const MARKDOWNLINT_CONFIG_FILES: &[&str] = &[
".markdownlint.json",
".markdownlint.jsonc",
".markdownlint.yaml",
".markdownlint.yml",
"markdownlint.json",
"markdownlint.jsonc",
"markdownlint.yaml",
"markdownlint.yml",
];
pub fn create_default_config(path: &str) -> Result<(), ConfigError> {
create_preset_config("default", path)
}
pub fn create_preset_config(preset: &str, path: &str) -> Result<(), ConfigError> {
if Path::new(path).exists() {
return Err(ConfigError::FileExists { path: path.to_string() });
}
let config_content = match preset {
"default" => generate_default_preset(),
"google" => generate_google_preset(),
"relaxed" => generate_relaxed_preset(),
_ => {
return Err(ConfigError::UnknownPreset {
name: preset.to_string(),
});
}
};
match fs::write(path, config_content) {
Ok(_) => Ok(()),
Err(err) => Err(ConfigError::IoError {
source: err,
path: path.to_string(),
}),
}
}
fn generate_default_preset() -> String {
r#"# rumdl configuration file
# Inherit settings from another config file (relative to this file's directory)
# extends = "../base.rumdl.toml"
# Global configuration options
[global]
# List of rules to disable (uncomment and modify as needed)
# disable = ["MD013", "MD033"]
# List of rules to enable exclusively (replaces defaults; only these rules will run)
# enable = ["MD001", "MD003", "MD004"]
# Additional rules to enable on top of defaults (additive, does not replace)
# Use this to activate opt-in rules like MD060, MD063, MD072, MD073, MD074
# extend-enable = ["MD060", "MD063"]
# Additional rules to disable on top of the disable list (additive)
# extend-disable = ["MD041"]
# List of file/directory patterns to include for linting (if provided, only these will be linted)
# include = [
# "docs/*.md",
# "src/**/*.md",
# "README.md"
# ]
# List of file/directory patterns to exclude from linting
exclude = [
# Common directories to exclude
".git",
".github",
"node_modules",
"vendor",
"dist",
"build",
# Specific files or patterns
"CHANGELOG.md",
"LICENSE.md",
]
# Respect .gitignore files when scanning directories (default: true)
respect-gitignore = true
# Markdown flavor/dialect (uncomment to enable)
# Options: standard (default), gfm, commonmark, mkdocs, mdx, quarto
# flavor = "mkdocs"
# Rule-specific configurations (uncomment and modify as needed)
# [MD003]
# style = "atx" # Heading style (atx, atx_closed, setext)
# [MD004]
# style = "asterisk" # Unordered list style (asterisk, plus, dash, consistent)
# [MD007]
# indent = 4 # Unordered list indentation
# [MD013]
# line-length = 100 # Line length
# code-blocks = false # Exclude code blocks from line length check
# tables = false # Exclude tables from line length check
# headings = true # Include headings in line length check
# [MD044]
# names = ["rumdl", "Markdown", "GitHub"] # Proper names that should be capitalized correctly
# code-blocks = false # Check code blocks for proper names (default: false, skips code blocks)
"#
.to_string()
}
fn generate_google_preset() -> String {
r#"# rumdl configuration - Google developer documentation style
# Based on https://google.github.io/styleguide/docguide/style.html
[global]
exclude = [
".git",
".github",
"node_modules",
"vendor",
"dist",
"build",
"CHANGELOG.md",
"LICENSE.md",
]
respect-gitignore = true
# ATX-style headings required
[MD003]
style = "atx"
# Unordered list style: dash
[MD004]
style = "dash"
# 4-space indent for nested lists
[MD007]
indent = 4
# Strict mode: no trailing spaces allowed (Google uses backslash for line breaks)
[MD009]
strict = true
# 80-character line length
[MD013]
line-length = 80
code-blocks = false
tables = false
# No trailing punctuation in headings
[MD026]
punctuation = ".,;:!。,;:!"
# Fenced code blocks only (no indented code blocks)
[MD046]
style = "fenced"
# Emphasis with underscores
[MD049]
style = "underscore"
# Strong with asterisks
[MD050]
style = "asterisk"
"#
.to_string()
}
fn generate_relaxed_preset() -> String {
r#"# rumdl configuration - Relaxed preset
# Lenient settings for existing projects adopting rumdl incrementally.
# Minimizes initial warnings while still catching important issues.
[global]
exclude = [
".git",
".github",
"node_modules",
"vendor",
"dist",
"build",
"CHANGELOG.md",
"LICENSE.md",
]
respect-gitignore = true
# Disable rules that produce the most noise on existing projects
disable = [
"MD013", # Line length - most existing files exceed 80 chars
"MD033", # Inline HTML - commonly used in real-world markdown
"MD041", # First line heading - not all files need it
]
# Consistent heading style (any style, just be consistent)
[MD003]
style = "consistent"
# Consistent list style
[MD004]
style = "consistent"
# Consistent emphasis style
[MD049]
style = "consistent"
# Consistent strong style
[MD050]
style = "consistent"
"#
.to_string()
}
#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
#[error("Failed to read config file at {path}: {source}")]
IoError { source: io::Error, path: String },
#[error("Failed to parse config: {0}")]
ParseError(String),
#[error("Configuration file already exists at {path}")]
FileExists { path: String },
#[error("Circular extends reference: {path} already in chain {chain:?}")]
CircularExtends { path: String, chain: Vec<String> },
#[error("extends chain exceeds maximum depth of {max_depth} at {path}")]
ExtendsDepthExceeded { path: String, max_depth: usize },
#[error("extends target not found: {path} (referenced from {from})")]
ExtendsNotFound { path: String, from: String },
#[error("Unknown preset: {name}. Valid presets: default, google, relaxed")]
UnknownPreset { name: String },
}
pub fn get_rule_config_value<T: serde::de::DeserializeOwned>(config: &Config, rule_name: &str, key: &str) -> Option<T> {
let norm_rule_name = rule_name.to_ascii_uppercase();
let rule_config = config.rules.get(&norm_rule_name)?;
let key_variants = [
key.to_string(), normalize_key(key), key.replace('-', "_"), key.replace('_', "-"), ];
for variant in &key_variants {
if let Some(value) = rule_config.values.get(variant)
&& let Ok(result) = T::deserialize(value.clone())
{
return Some(result);
}
}
None
}
pub fn generate_pyproject_preset_config(preset: &str) -> Result<String, ConfigError> {
match preset {
"default" => Ok(generate_pyproject_config()),
other => {
let rumdl_config = match other {
"google" => generate_google_preset(),
"relaxed" => generate_relaxed_preset(),
_ => {
return Err(ConfigError::UnknownPreset {
name: other.to_string(),
});
}
};
Ok(convert_rumdl_to_pyproject(&rumdl_config))
}
}
}
fn convert_rumdl_to_pyproject(rumdl_config: &str) -> String {
let mut output = String::with_capacity(rumdl_config.len() + 128);
for line in rumdl_config.lines() {
let trimmed = line.trim();
if trimmed.starts_with('[') && trimmed.ends_with(']') && !trimmed.starts_with("# [") {
let section = &trimmed[1..trimmed.len() - 1];
if section == "global" {
output.push_str("[tool.rumdl]");
} else {
output.push_str(&format!("[tool.rumdl.{section}]"));
}
} else {
output.push_str(line);
}
output.push('\n');
}
output
}
pub fn generate_pyproject_config() -> String {
let config_content = r#"
[tool.rumdl]
# Global configuration options
line-length = 100
disable = []
# extend-enable = ["MD060"] # Add opt-in rules (additive, keeps defaults)
# extend-disable = [] # Additional rules to disable (additive)
exclude = [
# Common directories to exclude
".git",
".github",
"node_modules",
"vendor",
"dist",
"build",
]
respect-gitignore = true
# Rule-specific configurations (uncomment and modify as needed)
# [tool.rumdl.MD003]
# style = "atx" # Heading style (atx, atx_closed, setext)
# [tool.rumdl.MD004]
# style = "asterisk" # Unordered list style (asterisk, plus, dash, consistent)
# [tool.rumdl.MD007]
# indent = 4 # Unordered list indentation
# [tool.rumdl.MD013]
# line-length = 100 # Line length
# code-blocks = false # Exclude code blocks from line length check
# tables = false # Exclude tables from line length check
# headings = true # Include headings in line length check
# [tool.rumdl.MD044]
# names = ["rumdl", "Markdown", "GitHub"] # Proper names that should be capitalized correctly
# code-blocks = false # Check code blocks for proper names (default: false, skips code blocks)
"#;
config_content.to_string()
}