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 apply_per_rule_enabled(&mut self) {
141 let mut to_enable: Vec<String> = Vec::new();
142 let mut to_disable: Vec<String> = Vec::new();
143
144 for (name, cfg) in &self.rules {
145 match cfg.values.get("enabled") {
146 Some(toml::Value::Boolean(true)) => {
147 to_enable.push(name.clone());
148 }
149 Some(toml::Value::Boolean(false)) => {
150 to_disable.push(name.clone());
151 }
152 _ => {}
153 }
154 }
155
156 for name in to_enable {
157 if !self.global.extend_enable.contains(&name) {
158 self.global.extend_enable.push(name.clone());
159 }
160 self.global.disable.retain(|n| n != &name);
161 self.global.extend_disable.retain(|n| n != &name);
162 }
163
164 for name in to_disable {
165 if !self.global.disable.contains(&name) {
166 self.global.disable.push(name.clone());
167 }
168 self.global.extend_enable.retain(|n| n != &name);
169 }
170 }
171
172 pub fn get_rule_severity(&self, rule_name: &str) -> Option<crate::rule::Severity> {
174 self.rules.get(rule_name).and_then(|r| r.severity)
175 }
176
177 pub fn get_ignored_rules_for_file(&self, file_path: &Path) -> HashSet<String> {
180 let mut ignored_rules = HashSet::new();
181
182 if self.per_file_ignores.is_empty() {
183 return ignored_rules;
184 }
185
186 let path_for_matching: std::borrow::Cow<'_, Path> = if let Some(ref root) = self.project_root {
189 if let Ok(canonical_path) = file_path.canonicalize() {
190 if let Ok(canonical_root) = root.canonicalize() {
191 if let Ok(relative) = canonical_path.strip_prefix(&canonical_root) {
192 std::borrow::Cow::Owned(relative.to_path_buf())
193 } else {
194 std::borrow::Cow::Borrowed(file_path)
195 }
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
206 let cache = self
207 .per_file_ignores_cache
208 .get_or_init(|| PerFileIgnoreCache::new(&self.per_file_ignores));
209
210 for match_idx in cache.globset.matches(path_for_matching.as_ref()) {
212 if let Some(rules) = cache.rules.get(match_idx) {
213 for rule in rules.iter() {
214 ignored_rules.insert(rule.clone());
216 }
217 }
218 }
219
220 ignored_rules
221 }
222
223 pub fn get_flavor_for_file(&self, file_path: &Path) -> MarkdownFlavor {
227 if self.per_file_flavor.is_empty() {
229 return self.resolve_flavor_fallback(file_path);
230 }
231
232 let path_for_matching: std::borrow::Cow<'_, Path> = if let Some(ref root) = self.project_root {
234 if let Ok(canonical_path) = file_path.canonicalize() {
235 if let Ok(canonical_root) = root.canonicalize() {
236 if let Ok(relative) = canonical_path.strip_prefix(&canonical_root) {
237 std::borrow::Cow::Owned(relative.to_path_buf())
238 } else {
239 std::borrow::Cow::Borrowed(file_path)
240 }
241 } else {
242 std::borrow::Cow::Borrowed(file_path)
243 }
244 } else {
245 std::borrow::Cow::Borrowed(file_path)
246 }
247 } else {
248 std::borrow::Cow::Borrowed(file_path)
249 };
250
251 let cache = self
252 .per_file_flavor_cache
253 .get_or_init(|| PerFileFlavorCache::new(&self.per_file_flavor));
254
255 for (matcher, flavor) in &cache.matchers {
257 if matcher.is_match(path_for_matching.as_ref()) {
258 return *flavor;
259 }
260 }
261
262 self.resolve_flavor_fallback(file_path)
264 }
265
266 fn resolve_flavor_fallback(&self, file_path: &Path) -> MarkdownFlavor {
268 if self.global.flavor != MarkdownFlavor::Standard {
270 return self.global.flavor;
271 }
272 MarkdownFlavor::from_path(file_path)
274 }
275
276 pub fn merge_with_inline_config(&self, inline_config: &crate::inline_config::InlineConfig) -> Self {
284 let overrides = inline_config.get_all_rule_configs();
285 if overrides.is_empty() {
286 return self.clone();
287 }
288
289 let mut merged = self.clone();
290
291 for (rule_name, json_override) in overrides {
292 let rule_config = merged.rules.entry(rule_name.clone()).or_default();
294
295 if let Some(obj) = json_override.as_object() {
297 for (key, value) in obj {
298 let normalized_key = key.replace('_', "-");
300
301 if let Some(toml_value) = json_to_toml(value) {
303 rule_config.values.insert(normalized_key, toml_value);
304 }
305 }
306 }
307 }
308
309 merged
310 }
311}
312
313pub(super) fn json_to_toml(json: &serde_json::Value) -> Option<toml::Value> {
315 match json {
316 serde_json::Value::Null => None,
317 serde_json::Value::Bool(b) => Some(toml::Value::Boolean(*b)),
318 serde_json::Value::Number(n) => n
319 .as_i64()
320 .map(toml::Value::Integer)
321 .or_else(|| n.as_f64().map(toml::Value::Float)),
322 serde_json::Value::String(s) => Some(toml::Value::String(s.clone())),
323 serde_json::Value::Array(arr) => {
324 let toml_arr: Vec<toml::Value> = arr.iter().filter_map(json_to_toml).collect();
325 Some(toml::Value::Array(toml_arr))
326 }
327 serde_json::Value::Object(obj) => {
328 let mut table = toml::map::Map::new();
329 for (k, v) in obj {
330 if let Some(tv) = json_to_toml(v) {
331 table.insert(k.clone(), tv);
332 }
333 }
334 Some(toml::Value::Table(table))
335 }
336 }
337}
338
339impl PerFileIgnoreCache {
340 fn new(per_file_ignores: &HashMap<String, Vec<String>>) -> Self {
341 let mut builder = GlobSetBuilder::new();
342 let mut rules = Vec::new();
343
344 for (pattern, rules_list) in per_file_ignores {
345 if let Ok(glob) = Glob::new(pattern) {
346 builder.add(glob);
347 rules.push(rules_list.iter().map(|rule| normalize_key(rule)).collect());
348 } else {
349 log::warn!("Invalid glob pattern in per-file-ignores: {pattern}");
350 }
351 }
352
353 let globset = builder.build().unwrap_or_else(|e| {
354 log::error!("Failed to build globset for per-file-ignores: {e}");
355 GlobSetBuilder::new().build().unwrap()
356 });
357
358 Self { globset, rules }
359 }
360}
361
362impl PerFileFlavorCache {
363 fn new(per_file_flavor: &IndexMap<String, MarkdownFlavor>) -> Self {
364 let mut matchers = Vec::new();
365
366 for (pattern, flavor) in per_file_flavor {
367 if let Ok(glob) = GlobBuilder::new(pattern).literal_separator(true).build() {
368 matchers.push((glob.compile_matcher(), *flavor));
369 } else {
370 log::warn!("Invalid glob pattern in per-file-flavor: {pattern}");
371 }
372 }
373
374 Self { matchers }
375 }
376}
377
378#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, schemars::JsonSchema)]
380#[serde(default, rename_all = "kebab-case")]
381pub struct GlobalConfig {
382 #[serde(default)]
384 pub enable: Vec<String>,
385
386 #[serde(default)]
388 pub disable: Vec<String>,
389
390 #[serde(default)]
392 pub exclude: Vec<String>,
393
394 #[serde(default)]
396 pub include: Vec<String>,
397
398 #[serde(default = "default_respect_gitignore", alias = "respect_gitignore")]
400 pub respect_gitignore: bool,
401
402 #[serde(default, alias = "line_length")]
404 pub line_length: LineLength,
405
406 #[serde(skip_serializing_if = "Option::is_none", alias = "output_format")]
408 pub output_format: Option<String>,
409
410 #[serde(default)]
413 pub fixable: Vec<String>,
414
415 #[serde(default)]
418 pub unfixable: Vec<String>,
419
420 #[serde(default)]
423 pub flavor: MarkdownFlavor,
424
425 #[serde(default, alias = "force_exclude")]
430 #[deprecated(since = "0.0.156", note = "Exclude patterns are now always respected")]
431 pub force_exclude: bool,
432
433 #[serde(default, alias = "cache_dir", skip_serializing_if = "Option::is_none")]
436 pub cache_dir: Option<String>,
437
438 #[serde(default = "default_true")]
441 pub cache: bool,
442
443 #[serde(default, alias = "extend_enable")]
445 pub extend_enable: Vec<String>,
446
447 #[serde(default, alias = "extend_disable")]
449 pub extend_disable: Vec<String>,
450
451 #[serde(skip)]
455 pub enable_is_explicit: bool,
456}
457
458fn default_respect_gitignore() -> bool {
459 true
460}
461
462fn default_true() -> bool {
463 true
464}
465
466impl Default for GlobalConfig {
468 #[allow(deprecated)]
469 fn default() -> Self {
470 Self {
471 enable: Vec::new(),
472 disable: Vec::new(),
473 exclude: Vec::new(),
474 include: Vec::new(),
475 respect_gitignore: true,
476 line_length: LineLength::default(),
477 output_format: None,
478 fixable: Vec::new(),
479 unfixable: Vec::new(),
480 flavor: MarkdownFlavor::default(),
481 force_exclude: false,
482 cache_dir: None,
483 cache: true,
484 extend_enable: Vec::new(),
485 extend_disable: Vec::new(),
486 enable_is_explicit: false,
487 }
488 }
489}
490
491pub const MARKDOWNLINT_CONFIG_FILES: &[&str] = &[
492 ".markdownlint-cli2.jsonc",
493 ".markdownlint-cli2.yaml",
494 ".markdownlint-cli2.yml",
495 ".markdownlint.json",
496 ".markdownlint.jsonc",
497 ".markdownlint.yaml",
498 ".markdownlint.yml",
499 "markdownlint.json",
500 "markdownlint.jsonc",
501 "markdownlint.yaml",
502 "markdownlint.yml",
503];
504
505pub fn create_default_config(path: &str) -> Result<(), ConfigError> {
507 create_preset_config("default", path)
508}
509
510pub fn create_preset_config(preset: &str, path: &str) -> Result<(), ConfigError> {
512 if Path::new(path).exists() {
513 return Err(ConfigError::FileExists { path: path.to_string() });
514 }
515
516 let config_content = match preset {
517 "default" => generate_default_preset(),
518 "google" => generate_google_preset(),
519 "relaxed" => generate_relaxed_preset(),
520 _ => {
521 return Err(ConfigError::UnknownPreset {
522 name: preset.to_string(),
523 });
524 }
525 };
526
527 match fs::write(path, config_content) {
528 Ok(_) => Ok(()),
529 Err(err) => Err(ConfigError::IoError {
530 source: err,
531 path: path.to_string(),
532 }),
533 }
534}
535
536fn generate_default_preset() -> String {
539 r#"# rumdl configuration file
540
541# Inherit settings from another config file (relative to this file's directory)
542# extends = "../base.rumdl.toml"
543
544# Global configuration options
545[global]
546# List of rules to disable (uncomment and modify as needed)
547# disable = ["MD013", "MD033"]
548
549# List of rules to enable exclusively (replaces defaults; only these rules will run)
550# enable = ["MD001", "MD003", "MD004"]
551
552# Additional rules to enable on top of defaults (additive, does not replace)
553# Use this to activate opt-in rules like MD060, MD063, MD072, MD073, MD074
554# extend-enable = ["MD060", "MD063"]
555
556# Additional rules to disable on top of the disable list (additive)
557# extend-disable = ["MD041"]
558
559# List of file/directory patterns to include for linting (if provided, only these will be linted)
560# include = [
561# "docs/*.md",
562# "src/**/*.md",
563# "README.md"
564# ]
565
566# List of file/directory patterns to exclude from linting
567exclude = [
568 # Common directories to exclude
569 ".git",
570 ".github",
571 "node_modules",
572 "vendor",
573 "dist",
574 "build",
575
576 # Specific files or patterns
577 "CHANGELOG.md",
578 "LICENSE.md",
579]
580
581# Respect .gitignore files when scanning directories (default: true)
582respect-gitignore = true
583
584# Markdown flavor/dialect (uncomment to enable)
585# Options: standard (default), gfm, commonmark, mkdocs, mdx, quarto
586# flavor = "mkdocs"
587
588# Rule-specific configurations (uncomment and modify as needed)
589
590# [MD003]
591# style = "atx" # Heading style (atx, atx_closed, setext)
592
593# [MD004]
594# style = "asterisk" # Unordered list style (asterisk, plus, dash, consistent)
595
596# [MD007]
597# indent = 4 # Unordered list indentation
598
599# [MD013]
600# line-length = 100 # Line length
601# code-blocks = false # Exclude code blocks from line length check
602# tables = false # Exclude tables from line length check
603# headings = true # Include headings in line length check
604
605# [MD044]
606# names = ["rumdl", "Markdown", "GitHub"] # Proper names that should be capitalized correctly
607# code-blocks = false # Check code blocks for proper names (default: false, skips code blocks)
608"#
609 .to_string()
610}
611
612fn generate_google_preset() -> String {
615 r#"# rumdl configuration - Google developer documentation style
616# Based on https://google.github.io/styleguide/docguide/style.html
617
618[global]
619exclude = [
620 ".git",
621 ".github",
622 "node_modules",
623 "vendor",
624 "dist",
625 "build",
626 "CHANGELOG.md",
627 "LICENSE.md",
628]
629respect-gitignore = true
630
631# ATX-style headings required
632[MD003]
633style = "atx"
634
635# Unordered list style: dash
636[MD004]
637style = "dash"
638
639# 4-space indent for nested lists
640[MD007]
641indent = 4
642
643# Strict mode: no trailing spaces allowed (Google uses backslash for line breaks)
644[MD009]
645strict = true
646
647# 80-character line length
648[MD013]
649line-length = 80
650code-blocks = false
651tables = false
652
653# No trailing punctuation in headings
654[MD026]
655punctuation = ".,;:!。,;:!"
656
657# Fenced code blocks only (no indented code blocks)
658[MD046]
659style = "fenced"
660
661# Emphasis with underscores
662[MD049]
663style = "underscore"
664
665# Strong with asterisks
666[MD050]
667style = "asterisk"
668"#
669 .to_string()
670}
671
672fn generate_relaxed_preset() -> String {
675 r#"# rumdl configuration - Relaxed preset
676# Lenient settings for existing projects adopting rumdl incrementally.
677# Minimizes initial warnings while still catching important issues.
678
679[global]
680exclude = [
681 ".git",
682 ".github",
683 "node_modules",
684 "vendor",
685 "dist",
686 "build",
687 "CHANGELOG.md",
688 "LICENSE.md",
689]
690respect-gitignore = true
691
692# Disable rules that produce the most noise on existing projects
693disable = [
694 "MD013", # Line length - most existing files exceed 80 chars
695 "MD033", # Inline HTML - commonly used in real-world markdown
696 "MD041", # First line heading - not all files need it
697]
698
699# Consistent heading style (any style, just be consistent)
700[MD003]
701style = "consistent"
702
703# Consistent list style
704[MD004]
705style = "consistent"
706
707# Consistent emphasis style
708[MD049]
709style = "consistent"
710
711# Consistent strong style
712[MD050]
713style = "consistent"
714"#
715 .to_string()
716}
717
718#[derive(Debug, thiserror::Error)]
720pub enum ConfigError {
721 #[error("Failed to read config file at {path}: {source}")]
723 IoError { source: io::Error, path: String },
724
725 #[error("Failed to parse config: {0}")]
727 ParseError(String),
728
729 #[error("Configuration file already exists at {path}")]
731 FileExists { path: String },
732
733 #[error("Circular extends reference: {path} already in chain {chain:?}")]
735 CircularExtends { path: String, chain: Vec<String> },
736
737 #[error("extends chain exceeds maximum depth of {max_depth} at {path}")]
739 ExtendsDepthExceeded { path: String, max_depth: usize },
740
741 #[error("extends target not found: {path} (referenced from {from})")]
743 ExtendsNotFound { path: String, from: String },
744
745 #[error("Unknown preset: {name}. Valid presets: default, google, relaxed")]
747 UnknownPreset { name: String },
748}
749
750pub fn get_rule_config_value<T: serde::de::DeserializeOwned>(config: &Config, rule_name: &str, key: &str) -> Option<T> {
754 let norm_rule_name = rule_name.to_ascii_uppercase(); let rule_config = config.rules.get(&norm_rule_name)?;
757
758 let key_variants = [
760 key.to_string(), normalize_key(key), key.replace('-', "_"), key.replace('_', "-"), ];
765
766 for variant in &key_variants {
768 if let Some(value) = rule_config.values.get(variant)
769 && let Ok(result) = T::deserialize(value.clone())
770 {
771 return Some(result);
772 }
773 }
774
775 None
776}
777
778pub fn generate_pyproject_preset_config(preset: &str) -> Result<String, ConfigError> {
781 match preset {
782 "default" => Ok(generate_pyproject_config()),
783 other => {
784 let rumdl_config = match other {
785 "google" => generate_google_preset(),
786 "relaxed" => generate_relaxed_preset(),
787 _ => {
788 return Err(ConfigError::UnknownPreset {
789 name: other.to_string(),
790 });
791 }
792 };
793 Ok(convert_rumdl_to_pyproject(&rumdl_config))
794 }
795 }
796}
797
798fn convert_rumdl_to_pyproject(rumdl_config: &str) -> String {
801 let mut output = String::with_capacity(rumdl_config.len() + 128);
802 for line in rumdl_config.lines() {
803 let trimmed = line.trim();
804 if trimmed.starts_with('[') && trimmed.ends_with(']') && !trimmed.starts_with("# [") {
805 let section = &trimmed[1..trimmed.len() - 1];
806 if section == "global" {
807 output.push_str("[tool.rumdl]");
808 } else {
809 output.push_str(&format!("[tool.rumdl.{section}]"));
810 }
811 } else {
812 output.push_str(line);
813 }
814 output.push('\n');
815 }
816 output
817}
818
819pub fn generate_pyproject_config() -> String {
821 let config_content = r#"
822[tool.rumdl]
823# Global configuration options
824line-length = 100
825disable = []
826# extend-enable = ["MD060"] # Add opt-in rules (additive, keeps defaults)
827# extend-disable = [] # Additional rules to disable (additive)
828exclude = [
829 # Common directories to exclude
830 ".git",
831 ".github",
832 "node_modules",
833 "vendor",
834 "dist",
835 "build",
836]
837respect-gitignore = true
838
839# Rule-specific configurations (uncomment and modify as needed)
840
841# [tool.rumdl.MD003]
842# style = "atx" # Heading style (atx, atx_closed, setext)
843
844# [tool.rumdl.MD004]
845# style = "asterisk" # Unordered list style (asterisk, plus, dash, consistent)
846
847# [tool.rumdl.MD007]
848# indent = 4 # Unordered list indentation
849
850# [tool.rumdl.MD013]
851# line-length = 100 # Line length
852# code-blocks = false # Exclude code blocks from line length check
853# tables = false # Exclude tables from line length check
854# headings = true # Include headings in line length check
855
856# [tool.rumdl.MD044]
857# names = ["rumdl", "Markdown", "GitHub"] # Proper names that should be capitalized correctly
858# code-blocks = false # Check code blocks for proper names (default: false, skips code blocks)
859"#;
860
861 config_content.to_string()
862}