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