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, skip_serializing_if = "Option::is_none")]
45 pub extends: Option<String>,
46
47 #[serde(default)]
49 pub global: GlobalConfig,
50
51 #[serde(default, rename = "per-file-ignores")]
54 pub per_file_ignores: HashMap<String, Vec<String>>,
55
56 #[serde(default, rename = "per-file-flavor")]
60 #[schemars(with = "HashMap<String, MarkdownFlavor>")]
61 pub per_file_flavor: IndexMap<String, MarkdownFlavor>,
62
63 #[serde(default, rename = "code-block-tools")]
66 pub code_block_tools: crate::code_block_tools::CodeBlockToolsConfig,
67
68 #[serde(flatten)]
79 pub rules: BTreeMap<String, RuleConfig>,
80
81 #[serde(skip)]
83 pub project_root: Option<std::path::PathBuf>,
84
85 #[serde(skip)]
86 #[schemars(skip)]
87 pub(super) per_file_ignores_cache: Arc<OnceLock<PerFileIgnoreCache>>,
88
89 #[serde(skip)]
90 #[schemars(skip)]
91 pub(super) per_file_flavor_cache: Arc<OnceLock<PerFileFlavorCache>>,
92}
93
94impl PartialEq for Config {
95 fn eq(&self, other: &Self) -> bool {
96 self.global == other.global
97 && self.per_file_ignores == other.per_file_ignores
98 && self.per_file_flavor == other.per_file_flavor
99 && self.code_block_tools == other.code_block_tools
100 && self.rules == other.rules
101 && self.project_root == other.project_root
102 }
103}
104
105#[derive(Debug)]
106pub(super) struct PerFileIgnoreCache {
107 globset: GlobSet,
108 rules: Vec<Vec<String>>,
109}
110
111#[derive(Debug)]
112pub(super) struct PerFileFlavorCache {
113 matchers: Vec<(GlobMatcher, MarkdownFlavor)>,
114}
115
116impl Config {
117 pub fn is_mkdocs_flavor(&self) -> bool {
119 self.global.flavor == MarkdownFlavor::MkDocs
120 }
121
122 pub fn markdown_flavor(&self) -> MarkdownFlavor {
128 self.global.flavor
129 }
130
131 pub fn is_mkdocs_project(&self) -> bool {
133 self.is_mkdocs_flavor()
134 }
135
136 pub fn apply_per_rule_enabled(&mut self) {
147 let mut to_enable: Vec<String> = Vec::new();
148 let mut to_disable: Vec<String> = Vec::new();
149
150 for (name, cfg) in &self.rules {
151 match cfg.values.get("enabled") {
152 Some(toml::Value::Boolean(true)) => {
153 to_enable.push(name.clone());
154 }
155 Some(toml::Value::Boolean(false)) => {
156 to_disable.push(name.clone());
157 }
158 _ => {}
159 }
160 }
161
162 for name in to_enable {
163 if !self.global.extend_enable.contains(&name) {
164 self.global.extend_enable.push(name.clone());
165 }
166 self.global.disable.retain(|n| n != &name);
167 self.global.extend_disable.retain(|n| n != &name);
168 }
169
170 for name in to_disable {
171 if !self.global.disable.contains(&name) {
172 self.global.disable.push(name.clone());
173 }
174 self.global.extend_enable.retain(|n| n != &name);
175 }
176 }
177
178 pub fn get_rule_severity(&self, rule_name: &str) -> Option<crate::rule::Severity> {
180 self.rules.get(rule_name).and_then(|r| r.severity)
181 }
182
183 pub fn get_ignored_rules_for_file(&self, file_path: &Path) -> HashSet<String> {
186 let mut ignored_rules = HashSet::new();
187
188 if self.per_file_ignores.is_empty() {
189 return ignored_rules;
190 }
191
192 let path_for_matching: std::borrow::Cow<'_, Path> = if let Some(ref root) = self.project_root {
195 if let Ok(canonical_path) = file_path.canonicalize() {
196 if let Ok(canonical_root) = root.canonicalize() {
197 if let Ok(relative) = canonical_path.strip_prefix(&canonical_root) {
198 std::borrow::Cow::Owned(relative.to_path_buf())
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 } else {
209 std::borrow::Cow::Borrowed(file_path)
210 };
211
212 let cache = self
213 .per_file_ignores_cache
214 .get_or_init(|| PerFileIgnoreCache::new(&self.per_file_ignores));
215
216 for match_idx in cache.globset.matches(path_for_matching.as_ref()) {
218 if let Some(rules) = cache.rules.get(match_idx) {
219 for rule in rules {
220 ignored_rules.insert(rule.clone());
222 }
223 }
224 }
225
226 ignored_rules
227 }
228
229 pub fn get_flavor_for_file(&self, file_path: &Path) -> MarkdownFlavor {
233 if self.per_file_flavor.is_empty() {
235 return self.resolve_flavor_fallback(file_path);
236 }
237
238 let path_for_matching: std::borrow::Cow<'_, Path> = if let Some(ref root) = self.project_root {
240 if let Ok(canonical_path) = file_path.canonicalize() {
241 if let Ok(canonical_root) = root.canonicalize() {
242 if let Ok(relative) = canonical_path.strip_prefix(&canonical_root) {
243 std::borrow::Cow::Owned(relative.to_path_buf())
244 } else {
245 std::borrow::Cow::Borrowed(file_path)
246 }
247 } else {
248 std::borrow::Cow::Borrowed(file_path)
249 }
250 } else {
251 std::borrow::Cow::Borrowed(file_path)
252 }
253 } else {
254 std::borrow::Cow::Borrowed(file_path)
255 };
256
257 let cache = self
258 .per_file_flavor_cache
259 .get_or_init(|| PerFileFlavorCache::new(&self.per_file_flavor));
260
261 for (matcher, flavor) in &cache.matchers {
263 if matcher.is_match(path_for_matching.as_ref()) {
264 return *flavor;
265 }
266 }
267
268 self.resolve_flavor_fallback(file_path)
270 }
271
272 fn resolve_flavor_fallback(&self, file_path: &Path) -> MarkdownFlavor {
274 if self.global.flavor != MarkdownFlavor::Standard {
276 return self.global.flavor;
277 }
278 MarkdownFlavor::from_path(file_path)
280 }
281
282 pub fn canonicalize_rule_lists(&mut self) {
298 use super::registry::canonicalize_rule_list_in_place;
299 self.global.canonicalize_rule_lists();
300 for rules in self.per_file_ignores.values_mut() {
301 canonicalize_rule_list_in_place(rules);
302 }
303 }
304
305 pub fn merge_with_inline_config(&self, inline_config: &crate::inline_config::InlineConfig) -> Self {
313 let overrides = inline_config.get_all_rule_configs();
314 if overrides.is_empty() {
315 return self.clone();
316 }
317
318 let mut merged = self.clone();
319
320 for (rule_name, json_override) in overrides {
321 let rule_config = merged.rules.entry(rule_name.clone()).or_default();
323
324 if let Some(obj) = json_override.as_object() {
326 for (key, value) in obj {
327 let normalized_key = key.replace('_', "-");
329
330 if let Some(toml_value) = json_to_toml(value) {
332 rule_config.values.insert(normalized_key, toml_value);
333 }
334 }
335 }
336 }
337
338 merged
339 }
340}
341
342pub(super) fn json_to_toml(json: &serde_json::Value) -> Option<toml::Value> {
344 match json {
345 serde_json::Value::Null => None,
346 serde_json::Value::Bool(b) => Some(toml::Value::Boolean(*b)),
347 serde_json::Value::Number(n) => n
348 .as_i64()
349 .map(toml::Value::Integer)
350 .or_else(|| n.as_f64().map(toml::Value::Float)),
351 serde_json::Value::String(s) => Some(toml::Value::String(s.clone())),
352 serde_json::Value::Array(arr) => {
353 let toml_arr: Vec<toml::Value> = arr.iter().filter_map(json_to_toml).collect();
354 Some(toml::Value::Array(toml_arr))
355 }
356 serde_json::Value::Object(obj) => {
357 let mut table = toml::map::Map::new();
358 for (k, v) in obj {
359 if let Some(tv) = json_to_toml(v) {
360 table.insert(k.clone(), tv);
361 }
362 }
363 Some(toml::Value::Table(table))
364 }
365 }
366}
367
368impl PerFileIgnoreCache {
369 fn new(per_file_ignores: &HashMap<String, Vec<String>>) -> Self {
370 let mut builder = GlobSetBuilder::new();
371 let mut rules = Vec::new();
372
373 for (pattern, rules_list) in per_file_ignores {
374 if let Ok(glob) = Glob::new(pattern) {
375 builder.add(glob);
376 rules.push(
382 rules_list
383 .iter()
384 .map(|rule| super::registry::resolve_rule_name(rule))
385 .collect(),
386 );
387 } else {
388 log::warn!("Invalid glob pattern in per-file-ignores: {pattern}");
389 }
390 }
391
392 let globset = builder.build().unwrap_or_else(|e| {
393 log::error!("Failed to build globset for per-file-ignores: {e}");
394 GlobSetBuilder::new().build().unwrap()
395 });
396
397 Self { globset, rules }
398 }
399}
400
401impl PerFileFlavorCache {
402 fn new(per_file_flavor: &IndexMap<String, MarkdownFlavor>) -> Self {
403 let mut matchers = Vec::new();
404
405 for (pattern, flavor) in per_file_flavor {
406 if let Ok(glob) = GlobBuilder::new(pattern).literal_separator(true).build() {
407 matchers.push((glob.compile_matcher(), *flavor));
408 } else {
409 log::warn!("Invalid glob pattern in per-file-flavor: {pattern}");
410 }
411 }
412
413 Self { matchers }
414 }
415}
416
417#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, schemars::JsonSchema)]
419#[serde(default, rename_all = "kebab-case")]
420pub struct GlobalConfig {
421 #[serde(default)]
423 pub enable: Vec<String>,
424
425 #[serde(default)]
427 pub disable: Vec<String>,
428
429 #[serde(default)]
431 pub exclude: Vec<String>,
432
433 #[serde(default)]
435 pub include: Vec<String>,
436
437 #[serde(default = "default_respect_gitignore", alias = "respect_gitignore")]
439 pub respect_gitignore: bool,
440
441 #[serde(default, alias = "line_length")]
443 pub line_length: LineLength,
444
445 #[serde(skip_serializing_if = "Option::is_none", alias = "output_format")]
447 pub output_format: Option<String>,
448
449 #[serde(default)]
452 pub fixable: Vec<String>,
453
454 #[serde(default)]
457 pub unfixable: Vec<String>,
458
459 #[serde(default)]
462 pub flavor: MarkdownFlavor,
463
464 #[serde(default, alias = "force_exclude")]
469 #[deprecated(since = "0.0.156", note = "Exclude patterns are now always respected")]
470 pub force_exclude: bool,
471
472 #[serde(default, alias = "cache_dir", skip_serializing_if = "Option::is_none")]
475 pub cache_dir: Option<String>,
476
477 #[serde(default = "default_true")]
480 pub cache: bool,
481
482 #[serde(default, alias = "extend_enable")]
484 pub extend_enable: Vec<String>,
485
486 #[serde(default, alias = "extend_disable")]
488 pub extend_disable: Vec<String>,
489
490 #[serde(skip)]
494 pub enable_is_explicit: bool,
495}
496
497fn default_respect_gitignore() -> bool {
498 true
499}
500
501fn default_true() -> bool {
502 true
503}
504
505impl Default for GlobalConfig {
507 #[allow(deprecated)]
508 fn default() -> Self {
509 Self {
510 enable: Vec::new(),
511 disable: Vec::new(),
512 exclude: Vec::new(),
513 include: Vec::new(),
514 respect_gitignore: true,
515 line_length: LineLength::default(),
516 output_format: None,
517 fixable: Vec::new(),
518 unfixable: Vec::new(),
519 flavor: MarkdownFlavor::default(),
520 force_exclude: false,
521 cache_dir: None,
522 cache: true,
523 extend_enable: Vec::new(),
524 extend_disable: Vec::new(),
525 enable_is_explicit: false,
526 }
527 }
528}
529
530impl GlobalConfig {
531 pub fn canonicalize_rule_lists(&mut self) {
545 use super::registry::canonicalize_rule_list_in_place;
546 canonicalize_rule_list_in_place(&mut self.enable);
547 canonicalize_rule_list_in_place(&mut self.disable);
548 canonicalize_rule_list_in_place(&mut self.extend_enable);
549 canonicalize_rule_list_in_place(&mut self.extend_disable);
550 canonicalize_rule_list_in_place(&mut self.fixable);
551 canonicalize_rule_list_in_place(&mut self.unfixable);
552 }
553}
554
555pub const RUMDL_CONFIG_FILES: &[&str] = &[".rumdl.toml", "rumdl.toml", ".config/rumdl.toml", "pyproject.toml"];
568
569pub const MARKDOWNLINT_CONFIG_FILES: &[&str] = &[
570 ".markdownlint-cli2.jsonc",
571 ".markdownlint-cli2.yaml",
572 ".markdownlint-cli2.yml",
573 ".markdownlint.json",
574 ".markdownlint.jsonc",
575 ".markdownlint.yaml",
576 ".markdownlint.yml",
577 "markdownlint.json",
578 "markdownlint.jsonc",
579 "markdownlint.yaml",
580 "markdownlint.yml",
581];
582
583pub fn create_default_config(path: &str) -> Result<(), ConfigError> {
585 create_preset_config("default", path)
586}
587
588pub fn create_preset_config(preset: &str, path: &str) -> Result<(), ConfigError> {
590 if Path::new(path).exists() {
591 return Err(ConfigError::FileExists { path: path.to_string() });
592 }
593
594 let config_content = match preset {
595 "default" => generate_default_preset(),
596 "google" => generate_google_preset(),
597 "relaxed" => generate_relaxed_preset(),
598 _ => {
599 return Err(ConfigError::UnknownPreset {
600 name: preset.to_string(),
601 });
602 }
603 };
604
605 match fs::write(path, config_content) {
606 Ok(_) => Ok(()),
607 Err(err) => Err(ConfigError::IoError {
608 source: err,
609 path: path.to_string(),
610 }),
611 }
612}
613
614fn generate_default_preset() -> String {
617 r#"# rumdl configuration file
618
619# Inherit settings from another config file (relative to this file's directory)
620# extends = "../base.rumdl.toml"
621
622# Global configuration options
623[global]
624# List of rules to disable (uncomment and modify as needed)
625# disable = ["MD013", "MD033"]
626
627# List of rules to enable exclusively (replaces defaults; only these rules will run)
628# enable = ["MD001", "MD003", "MD004"]
629
630# Additional rules to enable on top of defaults (additive, does not replace)
631# Use this to activate opt-in rules like MD060, MD063, MD072, MD073, MD074
632# extend-enable = ["MD060", "MD063"]
633
634# Additional rules to disable on top of the disable list (additive)
635# extend-disable = ["MD041"]
636
637# List of file/directory patterns to include for linting (if provided, only these will be linted)
638# include = [
639# "docs/*.md",
640# "src/**/*.md",
641# "README.md"
642# ]
643
644# List of file/directory patterns to exclude from linting
645exclude = [
646 # Common directories to exclude
647 ".git",
648 ".github",
649 "node_modules",
650 "vendor",
651 "dist",
652 "build",
653
654 # Specific files or patterns
655 "CHANGELOG.md",
656 "LICENSE.md",
657]
658
659# Respect .gitignore files when scanning directories (default: true)
660respect-gitignore = true
661
662# Markdown flavor/dialect (uncomment to enable)
663# Options: standard (default), gfm, commonmark, mkdocs, mdx, quarto
664# flavor = "mkdocs"
665
666# Rule-specific configurations (uncomment and modify as needed)
667
668# [MD003]
669# style = "atx" # Heading style (atx, atx_closed, setext)
670
671# [MD004]
672# style = "asterisk" # Unordered list style (asterisk, plus, dash, consistent)
673
674# [MD007]
675# indent = 4 # Unordered list indentation
676
677# [MD013]
678# line-length = 100 # Line length
679# code-blocks = false # Exclude code blocks from line length check
680# tables = false # Exclude tables from line length check
681# headings = true # Include headings in line length check
682
683# [MD044]
684# names = ["rumdl", "Markdown", "GitHub"] # Proper names that should be capitalized correctly
685# code-blocks = false # Check code blocks for proper names (default: false, skips code blocks)
686"#
687 .to_string()
688}
689
690fn generate_google_preset() -> String {
693 r#"# rumdl configuration - Google developer documentation style
694# Based on https://google.github.io/styleguide/docguide/style.html
695
696[global]
697exclude = [
698 ".git",
699 ".github",
700 "node_modules",
701 "vendor",
702 "dist",
703 "build",
704 "CHANGELOG.md",
705 "LICENSE.md",
706]
707respect-gitignore = true
708
709# ATX-style headings required
710[MD003]
711style = "atx"
712
713# Unordered list style: dash
714[MD004]
715style = "dash"
716
717# 4-space indent for nested lists
718[MD007]
719indent = 4
720
721# Strict mode: no trailing spaces allowed (Google uses backslash for line breaks)
722[MD009]
723strict = true
724
725# 80-character line length
726[MD013]
727line-length = 80
728code-blocks = false
729tables = false
730
731# No trailing punctuation in headings
732[MD026]
733punctuation = ".,;:!。,;:!"
734
735# Fenced code blocks only (no indented code blocks)
736[MD046]
737style = "fenced"
738
739# Emphasis with underscores
740[MD049]
741style = "underscore"
742
743# Strong with asterisks
744[MD050]
745style = "asterisk"
746"#
747 .to_string()
748}
749
750fn generate_relaxed_preset() -> String {
753 r#"# rumdl configuration - Relaxed preset
754# Lenient settings for existing projects adopting rumdl incrementally.
755# Minimizes initial warnings while still catching important issues.
756
757[global]
758exclude = [
759 ".git",
760 ".github",
761 "node_modules",
762 "vendor",
763 "dist",
764 "build",
765 "CHANGELOG.md",
766 "LICENSE.md",
767]
768respect-gitignore = true
769
770# Disable rules that produce the most noise on existing projects
771disable = [
772 "MD013", # Line length - most existing files exceed 80 chars
773 "MD033", # Inline HTML - commonly used in real-world markdown
774 "MD041", # First line heading - not all files need it
775]
776
777# Consistent heading style (any style, just be consistent)
778[MD003]
779style = "consistent"
780
781# Consistent list style
782[MD004]
783style = "consistent"
784
785# Consistent emphasis style
786[MD049]
787style = "consistent"
788
789# Consistent strong style
790[MD050]
791style = "consistent"
792"#
793 .to_string()
794}
795
796#[derive(Debug, thiserror::Error)]
798pub enum ConfigError {
799 #[error("Failed to read config file at {path}: {source}")]
801 IoError { source: io::Error, path: String },
802
803 #[error("Failed to parse config: {0}")]
805 ParseError(String),
806
807 #[error("Configuration file already exists at {path}")]
809 FileExists { path: String },
810
811 #[error("Circular extends reference: {path} already in chain {chain:?}")]
813 CircularExtends { path: String, chain: Vec<String> },
814
815 #[error("Extends chain exceeds maximum depth of {max_depth} at {path}")]
817 ExtendsDepthExceeded { path: String, max_depth: usize },
818
819 #[error("extends target not found: {path} (referenced from {from})")]
821 ExtendsNotFound { path: String, from: String },
822
823 #[error("Unknown preset: {name}. Valid presets: default, google, relaxed")]
825 UnknownPreset { name: String },
826}
827
828pub fn get_rule_config_value<T: serde::de::DeserializeOwned>(config: &Config, rule_name: &str, key: &str) -> Option<T> {
832 let norm_rule_name = rule_name.to_ascii_uppercase(); let rule_config = config.rules.get(&norm_rule_name)?;
835
836 let key_variants = [
838 key.to_string(), normalize_key(key), key.replace('-', "_"), key.replace('_', "-"), ];
843
844 for variant in &key_variants {
846 if let Some(value) = rule_config.values.get(variant)
847 && let Ok(result) = T::deserialize(value.clone())
848 {
849 return Some(result);
850 }
851 }
852
853 None
854}
855
856pub fn generate_pyproject_preset_config(preset: &str) -> Result<String, ConfigError> {
859 match preset {
860 "default" => Ok(generate_pyproject_config()),
861 other => {
862 let rumdl_config = match other {
863 "google" => generate_google_preset(),
864 "relaxed" => generate_relaxed_preset(),
865 _ => {
866 return Err(ConfigError::UnknownPreset {
867 name: other.to_string(),
868 });
869 }
870 };
871 Ok(convert_rumdl_to_pyproject(&rumdl_config))
872 }
873 }
874}
875
876fn convert_rumdl_to_pyproject(rumdl_config: &str) -> String {
879 let mut output = String::with_capacity(rumdl_config.len() + 128);
880 for line in rumdl_config.lines() {
881 let trimmed = line.trim();
882 if trimmed.starts_with('[') && trimmed.ends_with(']') && !trimmed.starts_with("# [") {
883 let section = &trimmed[1..trimmed.len() - 1];
884 if section == "global" {
885 output.push_str("[tool.rumdl]");
886 } else {
887 output.push_str(&format!("[tool.rumdl.{section}]"));
888 }
889 } else {
890 output.push_str(line);
891 }
892 output.push('\n');
893 }
894 output
895}
896
897pub fn generate_pyproject_config() -> String {
899 let config_content = r#"
900[tool.rumdl]
901# Global configuration options
902line-length = 100
903disable = []
904# extend-enable = ["MD060"] # Add opt-in rules (additive, keeps defaults)
905# extend-disable = [] # Additional rules to disable (additive)
906exclude = [
907 # Common directories to exclude
908 ".git",
909 ".github",
910 "node_modules",
911 "vendor",
912 "dist",
913 "build",
914]
915respect-gitignore = true
916
917# Rule-specific configurations (uncomment and modify as needed)
918
919# [tool.rumdl.MD003]
920# style = "atx" # Heading style (atx, atx_closed, setext)
921
922# [tool.rumdl.MD004]
923# style = "asterisk" # Unordered list style (asterisk, plus, dash, consistent)
924
925# [tool.rumdl.MD007]
926# indent = 4 # Unordered list indentation
927
928# [tool.rumdl.MD013]
929# line-length = 100 # Line length
930# code-blocks = false # Exclude code blocks from line length check
931# tables = false # Exclude tables from line length check
932# headings = true # Include headings in line length check
933
934# [tool.rumdl.MD044]
935# names = ["rumdl", "Markdown", "GitHub"] # Proper names that should be capitalized correctly
936# code-blocks = false # Check code blocks for proper names (default: false, skips code blocks)
937"#;
938
939 config_content.to_string()
940}