1use crate::types::LineLength;
2use globset::{Glob, GlobBuilder, GlobMatcher, GlobSet, GlobSetBuilder};
3use indexmap::IndexMap;
4use serde::{Deserialize, Serialize};
5use std::collections::BTreeMap;
6use std::collections::{HashMap, HashSet};
7use std::fs;
8use std::io;
9use std::path::Path;
10use std::sync::{Arc, OnceLock};
11
12use super::flavor::{MarkdownFlavor, normalize_key};
13
14#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, schemars::JsonSchema)]
16pub struct RuleConfig {
17 #[serde(default, skip_serializing_if = "Option::is_none")]
19 pub severity: Option<crate::rule::Severity>,
20
21 #[serde(flatten)]
23 #[schemars(schema_with = "arbitrary_value_schema")]
24 pub values: BTreeMap<String, toml::Value>,
25}
26
27fn arbitrary_value_schema(_gen: &mut schemars::SchemaGenerator) -> schemars::Schema {
29 schemars::json_schema!({
30 "type": "object",
31 "additionalProperties": true
32 })
33}
34
35#[derive(Debug, Clone, Serialize, Deserialize, Default, schemars::JsonSchema)]
37#[schemars(
38 description = "rumdl configuration for linting Markdown files. Rules can be configured individually using [MD###] sections with rule-specific options."
39)]
40pub struct Config {
41 #[serde(default)]
43 pub global: GlobalConfig,
44
45 #[serde(default, rename = "per-file-ignores")]
48 pub per_file_ignores: HashMap<String, Vec<String>>,
49
50 #[serde(default, rename = "per-file-flavor")]
54 #[schemars(with = "HashMap<String, MarkdownFlavor>")]
55 pub per_file_flavor: IndexMap<String, MarkdownFlavor>,
56
57 #[serde(default, rename = "code-block-tools")]
60 pub code_block_tools: crate::code_block_tools::CodeBlockToolsConfig,
61
62 #[serde(flatten)]
73 pub rules: BTreeMap<String, RuleConfig>,
74
75 #[serde(skip)]
77 pub project_root: Option<std::path::PathBuf>,
78
79 #[serde(skip)]
80 #[schemars(skip)]
81 pub(super) per_file_ignores_cache: Arc<OnceLock<PerFileIgnoreCache>>,
82
83 #[serde(skip)]
84 #[schemars(skip)]
85 pub(super) per_file_flavor_cache: Arc<OnceLock<PerFileFlavorCache>>,
86}
87
88impl PartialEq for Config {
89 fn eq(&self, other: &Self) -> bool {
90 self.global == other.global
91 && self.per_file_ignores == other.per_file_ignores
92 && self.per_file_flavor == other.per_file_flavor
93 && self.code_block_tools == other.code_block_tools
94 && self.rules == other.rules
95 && self.project_root == other.project_root
96 }
97}
98
99#[derive(Debug)]
100pub(super) struct PerFileIgnoreCache {
101 globset: GlobSet,
102 rules: Vec<Vec<String>>,
103}
104
105#[derive(Debug)]
106pub(super) struct PerFileFlavorCache {
107 matchers: Vec<(GlobMatcher, MarkdownFlavor)>,
108}
109
110impl Config {
111 pub fn is_mkdocs_flavor(&self) -> bool {
113 self.global.flavor == MarkdownFlavor::MkDocs
114 }
115
116 pub fn markdown_flavor(&self) -> MarkdownFlavor {
122 self.global.flavor
123 }
124
125 pub fn is_mkdocs_project(&self) -> bool {
127 self.is_mkdocs_flavor()
128 }
129
130 pub fn get_rule_severity(&self, rule_name: &str) -> Option<crate::rule::Severity> {
132 self.rules.get(rule_name).and_then(|r| r.severity)
133 }
134
135 pub fn get_ignored_rules_for_file(&self, file_path: &Path) -> HashSet<String> {
138 let mut ignored_rules = HashSet::new();
139
140 if self.per_file_ignores.is_empty() {
141 return ignored_rules;
142 }
143
144 let path_for_matching: std::borrow::Cow<'_, Path> = if let Some(ref root) = self.project_root {
147 if let Ok(canonical_path) = file_path.canonicalize() {
148 if let Ok(canonical_root) = root.canonicalize() {
149 if let Ok(relative) = canonical_path.strip_prefix(&canonical_root) {
150 std::borrow::Cow::Owned(relative.to_path_buf())
151 } else {
152 std::borrow::Cow::Borrowed(file_path)
153 }
154 } else {
155 std::borrow::Cow::Borrowed(file_path)
156 }
157 } else {
158 std::borrow::Cow::Borrowed(file_path)
159 }
160 } else {
161 std::borrow::Cow::Borrowed(file_path)
162 };
163
164 let cache = self
165 .per_file_ignores_cache
166 .get_or_init(|| PerFileIgnoreCache::new(&self.per_file_ignores));
167
168 for match_idx in cache.globset.matches(path_for_matching.as_ref()) {
170 if let Some(rules) = cache.rules.get(match_idx) {
171 for rule in rules.iter() {
172 ignored_rules.insert(rule.clone());
174 }
175 }
176 }
177
178 ignored_rules
179 }
180
181 pub fn get_flavor_for_file(&self, file_path: &Path) -> MarkdownFlavor {
185 if self.per_file_flavor.is_empty() {
187 return self.resolve_flavor_fallback(file_path);
188 }
189
190 let path_for_matching: std::borrow::Cow<'_, Path> = if let Some(ref root) = self.project_root {
192 if let Ok(canonical_path) = file_path.canonicalize() {
193 if let Ok(canonical_root) = root.canonicalize() {
194 if let Ok(relative) = canonical_path.strip_prefix(&canonical_root) {
195 std::borrow::Cow::Owned(relative.to_path_buf())
196 } else {
197 std::borrow::Cow::Borrowed(file_path)
198 }
199 } else {
200 std::borrow::Cow::Borrowed(file_path)
201 }
202 } else {
203 std::borrow::Cow::Borrowed(file_path)
204 }
205 } else {
206 std::borrow::Cow::Borrowed(file_path)
207 };
208
209 let cache = self
210 .per_file_flavor_cache
211 .get_or_init(|| PerFileFlavorCache::new(&self.per_file_flavor));
212
213 for (matcher, flavor) in &cache.matchers {
215 if matcher.is_match(path_for_matching.as_ref()) {
216 return *flavor;
217 }
218 }
219
220 self.resolve_flavor_fallback(file_path)
222 }
223
224 fn resolve_flavor_fallback(&self, file_path: &Path) -> MarkdownFlavor {
226 if self.global.flavor != MarkdownFlavor::Standard {
228 return self.global.flavor;
229 }
230 MarkdownFlavor::from_path(file_path)
232 }
233
234 pub fn merge_with_inline_config(&self, inline_config: &crate::inline_config::InlineConfig) -> Self {
242 let overrides = inline_config.get_all_rule_configs();
243 if overrides.is_empty() {
244 return self.clone();
245 }
246
247 let mut merged = self.clone();
248
249 for (rule_name, json_override) in overrides {
250 let rule_config = merged.rules.entry(rule_name.clone()).or_default();
252
253 if let Some(obj) = json_override.as_object() {
255 for (key, value) in obj {
256 let normalized_key = key.replace('_', "-");
258
259 if let Some(toml_value) = json_to_toml(value) {
261 rule_config.values.insert(normalized_key, toml_value);
262 }
263 }
264 }
265 }
266
267 merged
268 }
269}
270
271pub(super) fn json_to_toml(json: &serde_json::Value) -> Option<toml::Value> {
273 match json {
274 serde_json::Value::Null => None,
275 serde_json::Value::Bool(b) => Some(toml::Value::Boolean(*b)),
276 serde_json::Value::Number(n) => n
277 .as_i64()
278 .map(toml::Value::Integer)
279 .or_else(|| n.as_f64().map(toml::Value::Float)),
280 serde_json::Value::String(s) => Some(toml::Value::String(s.clone())),
281 serde_json::Value::Array(arr) => {
282 let toml_arr: Vec<toml::Value> = arr.iter().filter_map(json_to_toml).collect();
283 Some(toml::Value::Array(toml_arr))
284 }
285 serde_json::Value::Object(obj) => {
286 let mut table = toml::map::Map::new();
287 for (k, v) in obj {
288 if let Some(tv) = json_to_toml(v) {
289 table.insert(k.clone(), tv);
290 }
291 }
292 Some(toml::Value::Table(table))
293 }
294 }
295}
296
297impl PerFileIgnoreCache {
298 fn new(per_file_ignores: &HashMap<String, Vec<String>>) -> Self {
299 let mut builder = GlobSetBuilder::new();
300 let mut rules = Vec::new();
301
302 for (pattern, rules_list) in per_file_ignores {
303 if let Ok(glob) = Glob::new(pattern) {
304 builder.add(glob);
305 rules.push(rules_list.iter().map(|rule| normalize_key(rule)).collect());
306 } else {
307 log::warn!("Invalid glob pattern in per-file-ignores: {pattern}");
308 }
309 }
310
311 let globset = builder.build().unwrap_or_else(|e| {
312 log::error!("Failed to build globset for per-file-ignores: {e}");
313 GlobSetBuilder::new().build().unwrap()
314 });
315
316 Self { globset, rules }
317 }
318}
319
320impl PerFileFlavorCache {
321 fn new(per_file_flavor: &IndexMap<String, MarkdownFlavor>) -> Self {
322 let mut matchers = Vec::new();
323
324 for (pattern, flavor) in per_file_flavor {
325 if let Ok(glob) = GlobBuilder::new(pattern).literal_separator(true).build() {
326 matchers.push((glob.compile_matcher(), *flavor));
327 } else {
328 log::warn!("Invalid glob pattern in per-file-flavor: {pattern}");
329 }
330 }
331
332 Self { matchers }
333 }
334}
335
336#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, schemars::JsonSchema)]
338#[serde(default, rename_all = "kebab-case")]
339pub struct GlobalConfig {
340 #[serde(default)]
342 pub enable: Vec<String>,
343
344 #[serde(default)]
346 pub disable: Vec<String>,
347
348 #[serde(default)]
350 pub exclude: Vec<String>,
351
352 #[serde(default)]
354 pub include: Vec<String>,
355
356 #[serde(default = "default_respect_gitignore", alias = "respect_gitignore")]
358 pub respect_gitignore: bool,
359
360 #[serde(default, alias = "line_length")]
362 pub line_length: LineLength,
363
364 #[serde(skip_serializing_if = "Option::is_none", alias = "output_format")]
366 pub output_format: Option<String>,
367
368 #[serde(default)]
371 pub fixable: Vec<String>,
372
373 #[serde(default)]
376 pub unfixable: Vec<String>,
377
378 #[serde(default)]
381 pub flavor: MarkdownFlavor,
382
383 #[serde(default, alias = "force_exclude")]
388 #[deprecated(since = "0.0.156", note = "Exclude patterns are now always respected")]
389 pub force_exclude: bool,
390
391 #[serde(default, alias = "cache_dir", skip_serializing_if = "Option::is_none")]
394 pub cache_dir: Option<String>,
395
396 #[serde(default = "default_true")]
399 pub cache: bool,
400
401 #[serde(default, alias = "extend_enable")]
403 pub extend_enable: Vec<String>,
404
405 #[serde(default, alias = "extend_disable")]
407 pub extend_disable: Vec<String>,
408
409 #[serde(skip)]
413 pub enable_is_explicit: bool,
414}
415
416fn default_respect_gitignore() -> bool {
417 true
418}
419
420fn default_true() -> bool {
421 true
422}
423
424impl Default for GlobalConfig {
426 #[allow(deprecated)]
427 fn default() -> Self {
428 Self {
429 enable: Vec::new(),
430 disable: Vec::new(),
431 exclude: Vec::new(),
432 include: Vec::new(),
433 respect_gitignore: true,
434 line_length: LineLength::default(),
435 output_format: None,
436 fixable: Vec::new(),
437 unfixable: Vec::new(),
438 flavor: MarkdownFlavor::default(),
439 force_exclude: false,
440 cache_dir: None,
441 cache: true,
442 extend_enable: Vec::new(),
443 extend_disable: Vec::new(),
444 enable_is_explicit: false,
445 }
446 }
447}
448
449pub(crate) const MARKDOWNLINT_CONFIG_FILES: &[&str] = &[
450 ".markdownlint.json",
451 ".markdownlint.jsonc",
452 ".markdownlint.yaml",
453 ".markdownlint.yml",
454 "markdownlint.json",
455 "markdownlint.jsonc",
456 "markdownlint.yaml",
457 "markdownlint.yml",
458];
459
460pub fn create_default_config(path: &str) -> Result<(), ConfigError> {
462 create_preset_config("default", path)
463}
464
465pub fn create_preset_config(preset: &str, path: &str) -> Result<(), ConfigError> {
467 if Path::new(path).exists() {
468 return Err(ConfigError::FileExists { path: path.to_string() });
469 }
470
471 let config_content = match preset {
472 "default" => generate_default_preset(),
473 "google" => generate_google_preset(),
474 "relaxed" => generate_relaxed_preset(),
475 _ => {
476 return Err(ConfigError::UnknownPreset {
477 name: preset.to_string(),
478 });
479 }
480 };
481
482 match fs::write(path, config_content) {
483 Ok(_) => Ok(()),
484 Err(err) => Err(ConfigError::IoError {
485 source: err,
486 path: path.to_string(),
487 }),
488 }
489}
490
491fn generate_default_preset() -> String {
494 r#"# rumdl configuration file
495
496# Inherit settings from another config file (relative to this file's directory)
497# extends = "../base.rumdl.toml"
498
499# Global configuration options
500[global]
501# List of rules to disable (uncomment and modify as needed)
502# disable = ["MD013", "MD033"]
503
504# List of rules to enable exclusively (replaces defaults; only these rules will run)
505# enable = ["MD001", "MD003", "MD004"]
506
507# Additional rules to enable on top of defaults (additive, does not replace)
508# Use this to activate opt-in rules like MD060, MD063, MD072, MD073, MD074
509# extend-enable = ["MD060", "MD063"]
510
511# Additional rules to disable on top of the disable list (additive)
512# extend-disable = ["MD041"]
513
514# List of file/directory patterns to include for linting (if provided, only these will be linted)
515# include = [
516# "docs/*.md",
517# "src/**/*.md",
518# "README.md"
519# ]
520
521# List of file/directory patterns to exclude from linting
522exclude = [
523 # Common directories to exclude
524 ".git",
525 ".github",
526 "node_modules",
527 "vendor",
528 "dist",
529 "build",
530
531 # Specific files or patterns
532 "CHANGELOG.md",
533 "LICENSE.md",
534]
535
536# Respect .gitignore files when scanning directories (default: true)
537respect-gitignore = true
538
539# Markdown flavor/dialect (uncomment to enable)
540# Options: standard (default), gfm, commonmark, mkdocs, mdx, quarto
541# flavor = "mkdocs"
542
543# Rule-specific configurations (uncomment and modify as needed)
544
545# [MD003]
546# style = "atx" # Heading style (atx, atx_closed, setext)
547
548# [MD004]
549# style = "asterisk" # Unordered list style (asterisk, plus, dash, consistent)
550
551# [MD007]
552# indent = 4 # Unordered list indentation
553
554# [MD013]
555# line-length = 100 # Line length
556# code-blocks = false # Exclude code blocks from line length check
557# tables = false # Exclude tables from line length check
558# headings = true # Include headings in line length check
559
560# [MD044]
561# names = ["rumdl", "Markdown", "GitHub"] # Proper names that should be capitalized correctly
562# code-blocks = false # Check code blocks for proper names (default: false, skips code blocks)
563"#
564 .to_string()
565}
566
567fn generate_google_preset() -> String {
570 r#"# rumdl configuration - Google developer documentation style
571# Based on https://google.github.io/styleguide/docguide/style.html
572
573[global]
574exclude = [
575 ".git",
576 ".github",
577 "node_modules",
578 "vendor",
579 "dist",
580 "build",
581 "CHANGELOG.md",
582 "LICENSE.md",
583]
584respect-gitignore = true
585
586# ATX-style headings required
587[MD003]
588style = "atx"
589
590# Unordered list style: dash
591[MD004]
592style = "dash"
593
594# 4-space indent for nested lists
595[MD007]
596indent = 4
597
598# Strict mode: no trailing spaces allowed (Google uses backslash for line breaks)
599[MD009]
600strict = true
601
602# 80-character line length
603[MD013]
604line-length = 80
605code-blocks = false
606tables = false
607
608# No trailing punctuation in headings
609[MD026]
610punctuation = ".,;:!。,;:!"
611
612# Fenced code blocks only (no indented code blocks)
613[MD046]
614style = "fenced"
615
616# Emphasis with underscores
617[MD049]
618style = "underscore"
619
620# Strong with asterisks
621[MD050]
622style = "asterisk"
623"#
624 .to_string()
625}
626
627fn generate_relaxed_preset() -> String {
630 r#"# rumdl configuration - Relaxed preset
631# Lenient settings for existing projects adopting rumdl incrementally.
632# Minimizes initial warnings while still catching important issues.
633
634[global]
635exclude = [
636 ".git",
637 ".github",
638 "node_modules",
639 "vendor",
640 "dist",
641 "build",
642 "CHANGELOG.md",
643 "LICENSE.md",
644]
645respect-gitignore = true
646
647# Disable rules that produce the most noise on existing projects
648disable = [
649 "MD013", # Line length - most existing files exceed 80 chars
650 "MD033", # Inline HTML - commonly used in real-world markdown
651 "MD041", # First line heading - not all files need it
652]
653
654# Consistent heading style (any style, just be consistent)
655[MD003]
656style = "consistent"
657
658# Consistent list style
659[MD004]
660style = "consistent"
661
662# Consistent emphasis style
663[MD049]
664style = "consistent"
665
666# Consistent strong style
667[MD050]
668style = "consistent"
669"#
670 .to_string()
671}
672
673#[derive(Debug, thiserror::Error)]
675pub enum ConfigError {
676 #[error("Failed to read config file at {path}: {source}")]
678 IoError { source: io::Error, path: String },
679
680 #[error("Failed to parse config: {0}")]
682 ParseError(String),
683
684 #[error("Configuration file already exists at {path}")]
686 FileExists { path: String },
687
688 #[error("Circular extends reference: {path} already in chain {chain:?}")]
690 CircularExtends { path: String, chain: Vec<String> },
691
692 #[error("extends chain exceeds maximum depth of {max_depth} at {path}")]
694 ExtendsDepthExceeded { path: String, max_depth: usize },
695
696 #[error("extends target not found: {path} (referenced from {from})")]
698 ExtendsNotFound { path: String, from: String },
699
700 #[error("Unknown preset: {name}. Valid presets: default, google, relaxed")]
702 UnknownPreset { name: String },
703}
704
705pub fn get_rule_config_value<T: serde::de::DeserializeOwned>(config: &Config, rule_name: &str, key: &str) -> Option<T> {
709 let norm_rule_name = rule_name.to_ascii_uppercase(); let rule_config = config.rules.get(&norm_rule_name)?;
712
713 let key_variants = [
715 key.to_string(), normalize_key(key), key.replace('-', "_"), key.replace('_', "-"), ];
720
721 for variant in &key_variants {
723 if let Some(value) = rule_config.values.get(variant)
724 && let Ok(result) = T::deserialize(value.clone())
725 {
726 return Some(result);
727 }
728 }
729
730 None
731}
732
733pub fn generate_pyproject_preset_config(preset: &str) -> Result<String, ConfigError> {
736 match preset {
737 "default" => Ok(generate_pyproject_config()),
738 other => {
739 let rumdl_config = match other {
740 "google" => generate_google_preset(),
741 "relaxed" => generate_relaxed_preset(),
742 _ => {
743 return Err(ConfigError::UnknownPreset {
744 name: other.to_string(),
745 });
746 }
747 };
748 Ok(convert_rumdl_to_pyproject(&rumdl_config))
749 }
750 }
751}
752
753fn convert_rumdl_to_pyproject(rumdl_config: &str) -> String {
756 let mut output = String::with_capacity(rumdl_config.len() + 128);
757 for line in rumdl_config.lines() {
758 let trimmed = line.trim();
759 if trimmed.starts_with('[') && trimmed.ends_with(']') && !trimmed.starts_with("# [") {
760 let section = &trimmed[1..trimmed.len() - 1];
761 if section == "global" {
762 output.push_str("[tool.rumdl]");
763 } else {
764 output.push_str(&format!("[tool.rumdl.{section}]"));
765 }
766 } else {
767 output.push_str(line);
768 }
769 output.push('\n');
770 }
771 output
772}
773
774pub fn generate_pyproject_config() -> String {
776 let config_content = r#"
777[tool.rumdl]
778# Global configuration options
779line-length = 100
780disable = []
781# extend-enable = ["MD060"] # Add opt-in rules (additive, keeps defaults)
782# extend-disable = [] # Additional rules to disable (additive)
783exclude = [
784 # Common directories to exclude
785 ".git",
786 ".github",
787 "node_modules",
788 "vendor",
789 "dist",
790 "build",
791]
792respect-gitignore = true
793
794# Rule-specific configurations (uncomment and modify as needed)
795
796# [tool.rumdl.MD003]
797# style = "atx" # Heading style (atx, atx_closed, setext)
798
799# [tool.rumdl.MD004]
800# style = "asterisk" # Unordered list style (asterisk, plus, dash, consistent)
801
802# [tool.rumdl.MD007]
803# indent = 4 # Unordered list indentation
804
805# [tool.rumdl.MD013]
806# line-length = 100 # Line length
807# code-blocks = false # Exclude code blocks from line length check
808# tables = false # Exclude tables from line length check
809# headings = true # Include headings in line length check
810
811# [tool.rumdl.MD044]
812# names = ["rumdl", "Markdown", "GitHub"] # Proper names that should be capitalized correctly
813# code-blocks = false # Check code blocks for proper names (default: false, skips code blocks)
814"#;
815
816 config_content.to_string()
817}