rumdl_lib/config/types.rs
1use crate::discovery::PathAliases;
2use crate::types::LineLength;
3use globset::{Glob, GlobBuilder, GlobMatcher, GlobSet, GlobSetBuilder};
4use indexmap::IndexMap;
5use serde::{Deserialize, Serialize};
6use std::collections::{BTreeMap, HashSet};
7use std::fs;
8use std::io;
9use std::path::{Path, PathBuf};
10use std::sync::{Arc, OnceLock};
11
12use super::flavor::{MarkdownFlavor, normalize_key};
13
14/// Represents a rule-specific configuration
15#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, schemars::JsonSchema)]
16pub struct RuleConfig {
17 /// Severity override for this rule (Error, Warning, or Info)
18 #[serde(default, skip_serializing_if = "Option::is_none")]
19 pub severity: Option<crate::rule::Severity>,
20
21 /// Configuration values for the rule
22 #[serde(flatten)]
23 #[schemars(schema_with = "arbitrary_value_schema")]
24 pub values: BTreeMap<String, toml::Value>,
25}
26
27/// Generate a JSON schema for arbitrary configuration values
28fn arbitrary_value_schema(_gen: &mut schemars::SchemaGenerator) -> schemars::Schema {
29 schemars::json_schema!({
30 "type": "object",
31 "additionalProperties": true
32 })
33}
34
35/// Represents the complete configuration loaded from rumdl.toml
36#[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 /// Path to a base config file to inherit settings from.
42 /// Supports relative paths, absolute paths, `~/` for the home directory, and
43 /// `$VAR` / `${VAR}` environment-variable expansion (a literal `$` is written `$$`).
44 /// Example: `extends = "../base.rumdl.toml"` or `extends = "$GEM_PATH/base.rumdl.toml"`
45 #[serde(default, skip_serializing_if = "Option::is_none")]
46 pub extends: Option<String>,
47
48 /// Global configuration options
49 #[serde(default)]
50 pub global: GlobalConfig,
51
52 /// Per-file rule ignores: maps file patterns to lists of rules to ignore.
53 /// Patterns are relative to the project root; a leading `~/` expands to the
54 /// home directory and absolute paths are matched as written.
55 /// Example: { "README.md": ["MD033"], "docs/**/*.md": ["MD013"] }
56 #[serde(default, rename = "per-file-ignores")]
57 pub per_file_ignores: BTreeMap<String, Vec<String>>,
58
59 /// Per-file flavor overrides: maps file patterns to Markdown flavors.
60 /// Patterns are relative to the project root; a leading `~/` expands to the
61 /// home directory and absolute paths are matched as written.
62 /// Example: { "docs/**/*.md": MkDocs, "**/*.mdx": MDX }
63 /// Uses IndexMap to preserve config file order for "first match wins" semantics
64 #[serde(default, rename = "per-file-flavor")]
65 #[schemars(with = "BTreeMap<String, MarkdownFlavor>")]
66 pub per_file_flavor: IndexMap<String, MarkdownFlavor>,
67
68 /// Code block tools configuration for per-language linting and formatting
69 /// using external tools like ruff, prettier, shellcheck, etc.
70 #[serde(default, rename = "code-block-tools")]
71 pub code_block_tools: crate::code_block_tools::CodeBlockToolsConfig,
72
73 /// Rule-specific configurations (e.g., MD013, MD007, MD044)
74 /// Each rule section can contain options specific to that rule.
75 ///
76 /// Common examples:
77 /// - MD013: line_length, code_blocks, tables, headings
78 /// - MD007: indent
79 /// - MD003: style ("atx", "atx-closed", "setext")
80 /// - MD044: names (array of proper names to check)
81 ///
82 /// See <https://github.com/rvben/rumdl> for full rule documentation.
83 #[serde(flatten)]
84 pub rules: BTreeMap<String, RuleConfig>,
85
86 /// Rules holding at least one option value that came from a config file
87 /// whose contents may not be quoted back (an `extends` target, whose path
88 /// is arbitrary and whose text the extending project need not be able to
89 /// read). The values apply as written; only a message about one has to
90 /// leave it out.
91 ///
92 /// Granularity is the rule section, because that is what a rule's options
93 /// are deserialized as: an error names one value but not which key it came
94 /// from, so a section mixing an extended value with a directly named one
95 /// withholds both. Erring that way keeps a message from carrying text the
96 /// project never wrote.
97 ///
98 /// Provenance rather than configuration, so it stays out of the serialized
99 /// form and the JSON schema.
100 #[serde(skip)]
101 #[schemars(skip)]
102 pub withheld_rule_values: std::collections::BTreeSet<String>,
103
104 /// Project root directory, used for resolving relative paths in per-file-ignores
105 #[serde(skip)]
106 pub project_root: Option<std::path::PathBuf>,
107
108 #[serde(skip)]
109 #[schemars(skip)]
110 pub(super) per_file_ignores_cache: Arc<OnceLock<PerFileIgnoreCache>>,
111
112 #[serde(skip)]
113 #[schemars(skip)]
114 pub(super) per_file_flavor_cache: Arc<OnceLock<PerFileFlavorCache>>,
115
116 /// Lazily-computed canonical form of `project_root`.
117 ///
118 /// `normalize_match_path` needs the canonical project root to strip
119 /// prefixes from absolute file paths. Without this cache, every per-file
120 /// lookup would re-canonicalize the project root (one syscall per file).
121 ///
122 /// ## Invariants
123 ///
124 /// - **Single-shot**: computed once on first use of [`Config::canonical_project_root`].
125 /// - **Never invalidated**: callers must not mutate `project_root` after
126 /// the first call. `Config` is treated as immutable post-construction
127 /// (the same assumption as `per_file_ignores_cache` and `per_file_flavor_cache`).
128 /// - **Construction-time existence**: the cache stores `None` if
129 /// `project_root` is unset, missing on disk, or otherwise can't be
130 /// canonicalized. In practice `project_root` is set after walking up to
131 /// `.git`, so the directory always exists at the time the cache is first
132 /// read; if a caller sets `project_root` to a not-yet-existing path,
133 /// the cache will permanently store `None`.
134 /// - **`Arc` wrapping**: `Config` derives `Clone`, and clones share the
135 /// same `OnceLock` so a value computed by one clone is observable to all.
136 ///
137 /// `cwd` deliberately is NOT cached symmetrically: callers read it fresh
138 /// from `std::env::current_dir()` per call because tests (and embedding
139 /// hosts like LSP servers) may legitimately mutate the process cwd
140 /// between lookups.
141 #[serde(skip)]
142 #[schemars(skip)]
143 pub(super) canonical_project_root_cache: Arc<OnceLock<Option<PathBuf>>>,
144}
145
146impl PartialEq for Config {
147 fn eq(&self, other: &Self) -> bool {
148 self.global == other.global
149 && self.per_file_ignores == other.per_file_ignores
150 && self.per_file_flavor == other.per_file_flavor
151 && self.code_block_tools == other.code_block_tools
152 && self.rules == other.rules
153 && self.project_root == other.project_root
154 }
155}
156
157#[derive(Debug)]
158pub(super) struct PerFileIgnoreCache {
159 globset: GlobSet,
160 rules: Vec<Vec<String>>,
161 /// Whether any pattern is absolute, i.e. whether matching has to consider
162 /// the file's absolute path as well as its project-relative form.
163 has_absolute: bool,
164 /// Spellings of the file that absolute patterns reach through a symlink.
165 aliases: PathAliases,
166}
167
168#[derive(Debug)]
169pub(super) struct PerFileFlavorCache {
170 matchers: Vec<(GlobMatcher, MarkdownFlavor)>,
171 /// See [`PerFileIgnoreCache::has_absolute`].
172 has_absolute: bool,
173 /// See [`PerFileIgnoreCache::aliases`].
174 aliases: PathAliases,
175}
176
177/// The file's absolute path, for matching against absolute patterns. `None`
178/// when the caller has no absolute pattern to match (the common case, which
179/// must not pay for the canonicalization) or when the file cannot be resolved.
180fn absolute_match_path(file_path: &Path, has_absolute: bool) -> Option<PathBuf> {
181 if !has_absolute {
182 return None;
183 }
184 crate::discovery::canonicalize_for_matching(file_path)
185}
186
187/// The file's other spellings, for matching against absolute patterns that
188/// named a location through a symlink (`/var/folders/**` on macOS, whose files
189/// canonicalize to `/private/var/folders/…`). Empty for the configurations that
190/// have no such pattern, which is nearly all of them.
191fn alias_match_paths(aliases: &PathAliases, absolute: Option<&Path>) -> Vec<String> {
192 match absolute {
193 Some(absolute) if !aliases.is_empty() => aliases.spellings_of(absolute),
194 _ => Vec::new(),
195 }
196}
197
198impl Config {
199 /// Check if the Markdown flavor is set to MkDocs
200 pub fn is_mkdocs_flavor(&self) -> bool {
201 self.global.flavor == MarkdownFlavor::MkDocs
202 }
203
204 // Future methods for when GFM and CommonMark are implemented:
205 // pub fn is_gfm_flavor(&self) -> bool
206 // pub fn is_commonmark_flavor(&self) -> bool
207
208 /// Get the configured Markdown flavor
209 pub fn markdown_flavor(&self) -> MarkdownFlavor {
210 self.global.flavor
211 }
212
213 /// Legacy method for backwards compatibility - redirects to is_mkdocs_flavor
214 pub fn is_mkdocs_project(&self) -> bool {
215 self.is_mkdocs_flavor()
216 }
217
218 /// Apply per-rule `enabled` config to the global enable/disable lists.
219 ///
220 /// For `[MD060] enabled = true`: adds the rule to `extend_enable` and
221 /// removes it from `disable` and `extend_disable`, ensuring the rule is active.
222 ///
223 /// For `[MD041] enabled = false`: adds the rule to `disable` and
224 /// removes it from `extend_enable`, ensuring the rule is inactive.
225 ///
226 /// Per-rule `enabled` takes precedence over global lists when there
227 /// is a conflict, since it represents a more specific intent.
228 pub fn apply_per_rule_enabled(&mut self) {
229 let mut to_enable: Vec<String> = Vec::new();
230 let mut to_disable: Vec<String> = Vec::new();
231
232 for (name, cfg) in &self.rules {
233 match cfg.values.get("enabled") {
234 Some(toml::Value::Boolean(true)) => {
235 to_enable.push(name.clone());
236 }
237 Some(toml::Value::Boolean(false)) => {
238 to_disable.push(name.clone());
239 }
240 _ => {}
241 }
242 }
243
244 for name in to_enable {
245 if !self.global.extend_enable.contains(&name) {
246 self.global.extend_enable.push(name.clone());
247 }
248 self.global.disable.retain(|n| n != &name);
249 self.global.extend_disable.retain(|n| n != &name);
250 }
251
252 for name in to_disable {
253 if !self.global.disable.contains(&name) {
254 self.global.disable.push(name.clone());
255 }
256 self.global.extend_enable.retain(|n| n != &name);
257 }
258 }
259
260 /// Get the severity override for a specific rule, if configured
261 pub fn get_rule_severity(&self, rule_name: &str) -> Option<crate::rule::Severity> {
262 self.rules.get(rule_name).and_then(|r| r.severity)
263 }
264
265 /// Return the canonical form of `project_root`, computed once and cached.
266 ///
267 /// Returns `None` if `project_root` is unset, doesn't exist on disk, or
268 /// otherwise cannot be canonicalized. Subsequent calls reuse the cached
269 /// value, eliminating the per-file `canonicalize()` syscall that
270 /// `normalize_match_path` would otherwise perform.
271 pub(super) fn canonical_project_root(&self) -> Option<&Path> {
272 self.canonical_project_root_cache
273 .get_or_init(|| self.project_root.as_deref().and_then(|p| p.canonicalize().ok()))
274 .as_deref()
275 }
276
277 /// Get the set of rules that should be ignored for a specific file based on per-file-ignores configuration
278 /// Returns a HashSet of rule names (uppercase, e.g., "MD033") that match the given file path
279 pub fn get_ignored_rules_for_file(&self, file_path: &Path) -> HashSet<String> {
280 let mut ignored_rules = HashSet::new();
281
282 if self.per_file_ignores.is_empty() {
283 return ignored_rules;
284 }
285
286 let cwd = std::env::current_dir().ok();
287 let path_for_matching = normalize_match_path(file_path, self.canonical_project_root(), cwd.as_deref());
288
289 let cache = self
290 .per_file_ignores_cache
291 .get_or_init(|| PerFileIgnoreCache::new(&self.per_file_ignores));
292
293 // Match the file path against all patterns, by its project-relative
294 // form and - for absolute patterns, including expanded `~` ones - by
295 // its absolute path and by the spellings of that path a pattern named
296 // through a symlink.
297 let absolute = absolute_match_path(file_path, cache.has_absolute);
298 let aliases = alias_match_paths(&cache.aliases, absolute.as_deref());
299 let matches = cache
300 .globset
301 .matches(path_for_matching.as_ref())
302 .into_iter()
303 .chain(absolute.iter().flat_map(|abs| cache.globset.matches(abs)))
304 .chain(aliases.iter().flat_map(|alias| cache.globset.matches(alias)));
305
306 for match_idx in matches {
307 if let Some(rules) = cache.rules.get(match_idx) {
308 for rule in rules {
309 // Normalize rule names to uppercase (MD033, md033 -> MD033)
310 ignored_rules.insert(rule.clone());
311 }
312 }
313 }
314
315 ignored_rules
316 }
317
318 /// Get the MarkdownFlavor for a specific file based on per-file-flavor configuration.
319 /// Returns the first matching pattern's flavor, or falls back to global flavor,
320 /// or auto-detects from extension, or defaults to Standard.
321 pub fn get_flavor_for_file(&self, file_path: &Path) -> MarkdownFlavor {
322 // If no per-file patterns, use fallback logic
323 if self.per_file_flavor.is_empty() {
324 return self.resolve_flavor_fallback(file_path);
325 }
326
327 let cwd = std::env::current_dir().ok();
328 let path_for_matching = normalize_match_path(file_path, self.canonical_project_root(), cwd.as_deref());
329
330 let cache = self
331 .per_file_flavor_cache
332 .get_or_init(|| PerFileFlavorCache::new(&self.per_file_flavor));
333
334 // Iterate in config order and return first match (IndexMap preserves order).
335 // Each pattern sees the file's project-relative form and - for absolute
336 // patterns, including expanded `~` ones - its absolute path and the
337 // spellings of that path a pattern named through a symlink.
338 let absolute = absolute_match_path(file_path, cache.has_absolute);
339 let aliases = alias_match_paths(&cache.aliases, absolute.as_deref());
340 for (matcher, flavor) in &cache.matchers {
341 if matcher.is_match(path_for_matching.as_ref())
342 || absolute.as_ref().is_some_and(|abs| matcher.is_match(abs))
343 || aliases.iter().any(|alias| matcher.is_match(alias))
344 {
345 return *flavor;
346 }
347 }
348
349 // No pattern matched, use fallback
350 self.resolve_flavor_fallback(file_path)
351 }
352
353 /// Fallback flavor resolution: global flavor → auto-detect → Standard
354 fn resolve_flavor_fallback(&self, file_path: &Path) -> MarkdownFlavor {
355 // If global flavor is explicitly set to non-Standard, use it
356 if self.global.flavor != MarkdownFlavor::Standard {
357 return self.global.flavor;
358 }
359 // Auto-detect from extension
360 MarkdownFlavor::from_path(file_path)
361 }
362
363 /// Canonicalize every rule-name list inside this `Config`.
364 ///
365 /// This is the single enforcement point for the runtime invariant:
366 /// **after a `Config` is fully built, every rule-name list contains
367 /// canonical rule IDs (`"MD033"`) — never aliases (`"no-inline-html"`).**
368 ///
369 /// The invariant lets every consumer (`rules::filter_rules`, the LSP,
370 /// WASM, fix coordinator, per-file-ignore lookups) match against
371 /// `Rule::name()` with simple string equality. Mutation boundaries
372 /// (`From<SourcedConfig> for Config`, LSP `apply_lsp_settings_*`, WASM
373 /// `to_config_with_warnings`) call this before handing the `Config` to
374 /// the linting pipeline.
375 ///
376 /// Covers `global.{enable,disable,extend_enable,extend_disable,fixable,unfixable}`
377 /// and the values of `per_file_ignores`. Idempotent.
378 pub fn canonicalize_rule_lists(&mut self) {
379 use super::registry::canonicalize_rule_list_in_place;
380 self.global.canonicalize_rule_lists();
381 for rules in self.per_file_ignores.values_mut() {
382 canonicalize_rule_list_in_place(rules);
383 }
384 }
385
386 /// Merge inline configuration overrides into a copy of this config
387 ///
388 /// This enables automatic inline config support - the engine can merge
389 /// inline overrides and recreate rules without any per-rule changes.
390 ///
391 /// Returns a new Config with the inline overrides merged in.
392 /// If there are no inline overrides, returns a clone of self.
393 pub fn merge_with_inline_config(&self, inline_config: &crate::inline_config::InlineConfig) -> Self {
394 let overrides = inline_config.get_all_rule_configs();
395 if overrides.is_empty() {
396 return self.clone();
397 }
398
399 let mut merged = self.clone();
400
401 for (rule_name, json_override) in overrides {
402 // Get or create the rule config entry
403 let rule_config = merged.rules.entry(rule_name.clone()).or_default();
404
405 // Merge JSON values into the rule's config
406 if let Some(obj) = json_override.as_object() {
407 for (key, value) in obj {
408 // Normalize key to kebab-case for consistency
409 let normalized_key = key.replace('_', "-");
410
411 // Convert JSON value to TOML value
412 if let Some(toml_value) = json_to_toml(value) {
413 rule_config.values.insert(normalized_key, toml_value);
414 }
415 }
416 }
417 }
418
419 merged
420 }
421}
422
423/// Normalize a file path for matching against a glob pattern from configuration.
424///
425/// Glob patterns in `per-file-ignores` and `per-file-flavor` are written relative
426/// to the project root (e.g. `docs/**/*.md`), and a glob is anchored at the start
427/// of the string it matches, so an absolute path like `/home/user/proj/docs/x.md`
428/// will not match `docs/**/*.md`. This helper produces the form the glob expects:
429///
430/// 1. **Relative path** → return as-is.
431/// 2. **Absolute path under `project_root`** → return path relative to `project_root`.
432/// 3. **Absolute path under `cwd`** → return path relative to `cwd`. This is the
433/// safety net for invocations where `project_root` could not be discovered
434/// (no `.git` upward, LSP/CLI calls outside a project) but the file still
435/// lives somewhere under the working directory.
436/// 4. **Anywhere else** → return the raw path. A relative glob simply won't
437/// match it, which is the desired outcome for files outside any known root.
438///
439/// All canonicalization failures degrade gracefully to step 4 so editor buffers
440/// and pre-creation paths still flow through without panicking.
441///
442/// `canonical_project_root` is expected to already be canonical (via
443/// `Config::canonical_project_root`). `cwd` is canonicalized internally on each
444/// call since it is read fresh from the environment per invocation.
445pub(super) fn normalize_match_path<'a>(
446 file_path: &'a Path,
447 canonical_project_root: Option<&Path>,
448 cwd: Option<&Path>,
449) -> std::borrow::Cow<'a, Path> {
450 use std::borrow::Cow;
451
452 if file_path.is_relative() {
453 return Cow::Borrowed(file_path);
454 }
455
456 let Ok(canonical_file) = file_path.canonicalize() else {
457 log::debug!(
458 "normalize_match_path: canonicalize failed for {}; returning raw path. \
459 Per-file glob patterns may not match (file may not yet exist on disk).",
460 file_path.display()
461 );
462 return Cow::Borrowed(file_path);
463 };
464
465 if let Some(root) = canonical_project_root
466 && let Ok(rel) = canonical_file.strip_prefix(root)
467 {
468 return Cow::Owned(rel.to_path_buf());
469 }
470
471 if let Some(working_dir) = cwd
472 && let Ok(canonical_cwd) = working_dir.canonicalize()
473 && let Ok(rel) = canonical_file.strip_prefix(&canonical_cwd)
474 {
475 return Cow::Owned(rel.to_path_buf());
476 }
477
478 // Surface the silent fallback once per process at warn level so users with
479 // per-file glob configs notice when their patterns can't match a file.
480 // Subsequent occurrences stay at debug to avoid log spam.
481 static SILENT_FALLBACK_WARNED: OnceLock<()> = OnceLock::new();
482 log::log!(
483 first_call_warn_else_debug(&SILENT_FALLBACK_WARNED),
484 "{}",
485 format_silent_fallback_message(file_path, canonical_project_root, cwd),
486 );
487 Cow::Borrowed(file_path)
488}
489
490/// Returns [`log::Level::Warn`] the first time it is called with a given
491/// `latch`, and [`log::Level::Debug`] on every subsequent call. The latch
492/// is consumed by the first caller via `OnceLock::set`; later callers
493/// observe the latch as already set and downgrade.
494///
495/// Used to flag a fallback condition once per process without flooding
496/// logs when the same condition recurs (e.g. once per linted file).
497pub(super) fn first_call_warn_else_debug(latch: &OnceLock<()>) -> log::Level {
498 if latch.set(()).is_ok() {
499 log::Level::Warn
500 } else {
501 log::Level::Debug
502 }
503}
504
505/// Format the diagnostic emitted when [`normalize_match_path`] cannot
506/// relativise `file_path` against either the project root or the current
507/// working directory. Extracted so the exact wording can be asserted in
508/// tests without capturing log output.
509pub(super) fn format_silent_fallback_message(
510 file_path: &Path,
511 canonical_project_root: Option<&Path>,
512 cwd: Option<&Path>,
513) -> String {
514 format!(
515 "Per-file glob patterns will not match {}: file is outside project_root ({}) and cwd ({})",
516 file_path.display(),
517 DisplayPathOrUnset(canonical_project_root),
518 DisplayPathOrUnset(cwd),
519 )
520}
521
522/// Display adapter for `Option<&Path>` that renders the path via
523/// [`Path::display`] when present, or the literal `<unset>` when absent.
524/// Angle brackets follow Rust's diagnostic convention (e.g. `<unknown>`)
525/// and avoid double-paren rendering when the surrounding format string
526/// already wraps the value in `(…)`.
527struct DisplayPathOrUnset<'a>(Option<&'a Path>);
528
529impl std::fmt::Display for DisplayPathOrUnset<'_> {
530 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
531 match self.0 {
532 Some(path) => std::fmt::Display::fmt(&path.display(), f),
533 None => f.write_str("<unset>"),
534 }
535 }
536}
537
538/// Convert a serde_json::Value to a toml::Value
539pub(super) fn json_to_toml(json: &serde_json::Value) -> Option<toml::Value> {
540 match json {
541 serde_json::Value::Null => None,
542 serde_json::Value::Bool(b) => Some(toml::Value::Boolean(*b)),
543 serde_json::Value::Number(n) => n
544 .as_i64()
545 .map(toml::Value::Integer)
546 .or_else(|| n.as_f64().map(toml::Value::Float)),
547 serde_json::Value::String(s) => Some(toml::Value::String(s.clone())),
548 serde_json::Value::Array(arr) => {
549 let toml_arr: Vec<toml::Value> = arr.iter().filter_map(json_to_toml).collect();
550 Some(toml::Value::Array(toml_arr))
551 }
552 serde_json::Value::Object(obj) => {
553 let mut table = toml::map::Map::new();
554 for (k, v) in obj {
555 if let Some(tv) = json_to_toml(v) {
556 table.insert(k.clone(), tv);
557 }
558 }
559 Some(toml::Value::Table(table))
560 }
561 }
562}
563
564impl PerFileIgnoreCache {
565 fn new(per_file_ignores: &BTreeMap<String, Vec<String>>) -> Self {
566 let mut builder = GlobSetBuilder::new();
567 let mut rules = Vec::new();
568
569 let mut has_absolute = false;
570 for (pattern, rules_list) in per_file_ignores {
571 let pattern = crate::discovery::expand_home_prefix(pattern);
572 has_absolute |= crate::discovery::has_absolute_spelling(&pattern);
573 if let Ok(glob) = Glob::new(&pattern) {
574 builder.add(glob);
575 // Canonicalize defensively: callers should have run
576 // Config::canonicalize_rule_lists already, but per-file-ignores
577 // has reached this cache directly from a few code paths
578 // historically, so we re-canonicalize here to keep the cache
579 // sound regardless of caller discipline.
580 rules.push(
581 rules_list
582 .iter()
583 .map(|rule| super::registry::resolve_rule_name(rule))
584 .collect(),
585 );
586 } else {
587 log::warn!("Invalid glob pattern in per-file-ignores: {pattern}");
588 }
589 }
590
591 let globset = builder.build().unwrap_or_else(|e| {
592 log::error!("Failed to build globset for per-file-ignores: {e}");
593 GlobSetBuilder::new().build().unwrap()
594 });
595
596 Self {
597 globset,
598 rules,
599 has_absolute,
600 aliases: PathAliases::new(per_file_ignores.keys().map(String::as_str)),
601 }
602 }
603}
604
605impl PerFileFlavorCache {
606 fn new(per_file_flavor: &IndexMap<String, MarkdownFlavor>) -> Self {
607 let mut matchers = Vec::new();
608
609 let mut has_absolute = false;
610 for (pattern, flavor) in per_file_flavor {
611 let pattern = crate::discovery::expand_home_prefix(pattern);
612 has_absolute |= crate::discovery::has_absolute_spelling(&pattern);
613 if let Ok(glob) = GlobBuilder::new(&pattern).literal_separator(true).build() {
614 matchers.push((glob.compile_matcher(), *flavor));
615 } else {
616 log::warn!("Invalid glob pattern in per-file-flavor: {pattern}");
617 }
618 }
619
620 Self {
621 matchers,
622 has_absolute,
623 aliases: PathAliases::new(per_file_flavor.keys().map(String::as_str)),
624 }
625 }
626}
627
628/// Global configuration options
629#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, schemars::JsonSchema)]
630#[serde(default, rename_all = "kebab-case")]
631pub struct GlobalConfig {
632 /// Enabled rules
633 #[serde(default)]
634 pub enable: Vec<String>,
635
636 /// Disabled rules
637 #[serde(default)]
638 pub disable: Vec<String>,
639
640 /// Files to exclude. Glob patterns, relative to the project root; a
641 /// leading `~/` expands to the home directory and absolute paths are
642 /// matched as written.
643 #[serde(default)]
644 pub exclude: Vec<String>,
645
646 /// Files to include. Glob patterns, relative to the project root; a
647 /// leading `~/` expands to the home directory and absolute paths are
648 /// matched as written.
649 #[serde(default)]
650 pub include: Vec<String>,
651
652 /// Respect .gitignore files when scanning directories
653 #[serde(default = "default_respect_gitignore", alias = "respect_gitignore")]
654 pub respect_gitignore: bool,
655
656 /// Global line length setting (used by MD013 and other rules if not overridden)
657 #[serde(default, alias = "line_length")]
658 pub line_length: LineLength,
659
660 /// Output format for linting results (e.g., "text", "json", "pylint", etc.)
661 #[serde(skip_serializing_if = "Option::is_none", alias = "output_format")]
662 pub output_format: Option<String>,
663
664 /// Rules that are allowed to be fixed when --fix is used
665 /// If specified, only these rules will be fixed
666 #[serde(default)]
667 pub fixable: Vec<String>,
668
669 /// Rules that should never be fixed, even when --fix is used
670 /// Takes precedence over fixable
671 #[serde(default)]
672 pub unfixable: Vec<String>,
673
674 /// Markdown flavor/dialect to use (mkdocs, gfm, commonmark, etc.)
675 /// When set, adjusts parsing and validation rules for that specific Markdown variant
676 #[serde(default)]
677 pub flavor: MarkdownFlavor,
678
679 /// \[DEPRECATED\] Whether to enforce exclude patterns for explicitly passed paths.
680 /// This option is deprecated as of v0.0.156 and has no effect.
681 /// Exclude patterns are now always respected, even for explicitly provided files.
682 /// This prevents duplication between rumdl config and tool configs like pre-commit.
683 #[serde(default, alias = "force_exclude")]
684 #[deprecated(since = "0.0.156", note = "Exclude patterns are now always respected")]
685 pub force_exclude: bool,
686
687 /// Directory to store cache files (default: .rumdl_cache).
688 /// A leading `~/` expands to the home directory; a relative path resolves
689 /// against the project root.
690 /// Can also be set via --cache-dir CLI flag or RUMDL_CACHE_DIR environment variable
691 #[serde(default, alias = "cache_dir", skip_serializing_if = "Option::is_none")]
692 pub cache_dir: Option<String>,
693
694 /// Whether caching is enabled (default: true)
695 /// Can also be disabled via --no-cache CLI flag
696 #[serde(default = "default_true")]
697 pub cache: bool,
698
699 /// Additional rules to enable on top of the base set (additive)
700 #[serde(default, alias = "extend_enable")]
701 pub extend_enable: Vec<String>,
702
703 /// Additional rules to disable on top of the base set (additive)
704 #[serde(default, alias = "extend_disable")]
705 pub extend_disable: Vec<String>,
706
707 /// Whether to read settings from `.editorconfig` files (default: false).
708 /// When enabled, the `.editorconfig` properties that map onto rumdl
709 /// settings fill in anything no rumdl config sets, resolved per file so
710 /// section globs and nested `.editorconfig` files apply as written.
711 #[serde(default)]
712 pub editorconfig: bool,
713
714 /// Whether the enable list was explicitly set (even if empty).
715 /// Used to distinguish "no enable list configured" from "enable list is empty"
716 /// (e.g., markdownlint `default: false` with no rules enabled).
717 #[serde(skip)]
718 pub enable_is_explicit: bool,
719
720 /// How to name the file that supplied [`Self::include`] when a message about
721 /// those patterns may not quote them (an `extends` target, whose path is
722 /// arbitrary and whose text the extending project need not be able to read),
723 /// and `None` when it may. Provenance rather than configuration, so it stays
724 /// out of the serialized form and the JSON schema.
725 #[serde(skip)]
726 pub include_withheld: Option<String>,
727}
728
729fn default_respect_gitignore() -> bool {
730 true
731}
732
733fn default_true() -> bool {
734 true
735}
736
737// Add the Default impl
738impl Default for GlobalConfig {
739 #[allow(deprecated)]
740 fn default() -> Self {
741 Self {
742 enable: Vec::new(),
743 disable: Vec::new(),
744 exclude: Vec::new(),
745 include: Vec::new(),
746 respect_gitignore: true,
747 line_length: LineLength::default(),
748 output_format: None,
749 fixable: Vec::new(),
750 unfixable: Vec::new(),
751 flavor: MarkdownFlavor::default(),
752 force_exclude: false,
753 cache_dir: None,
754 cache: true,
755 extend_enable: Vec::new(),
756 extend_disable: Vec::new(),
757 editorconfig: false,
758 enable_is_explicit: false,
759 include_withheld: None,
760 }
761 }
762}
763
764impl GlobalConfig {
765 /// Canonicalize every rule-name list in this `GlobalConfig`.
766 ///
767 /// Rewrites `enable`, `disable`, `extend_enable`, `extend_disable`, `fixable`,
768 /// and `unfixable` so that all entries are canonical rule IDs (`"MD033"`)
769 /// rather than aliases (`"no-inline-html"`). Duplicates are removed,
770 /// preserving first-occurrence order; the special `"all"` keyword is
771 /// preserved.
772 ///
773 /// This must be called by every code path that mutates a runtime
774 /// `Config`'s rule lists from external input (markdownlint configs,
775 /// `.rumdl.toml`, LSP `initializationOptions`, WASM bindings, etc.) so
776 /// that downstream consumers (`rules::filter_rules`, the LSP, WASM) can
777 /// match against `Rule::name()` with simple string equality.
778 pub fn canonicalize_rule_lists(&mut self) {
779 use super::registry::canonicalize_rule_list_in_place;
780 canonicalize_rule_list_in_place(&mut self.enable);
781 canonicalize_rule_list_in_place(&mut self.disable);
782 canonicalize_rule_list_in_place(&mut self.extend_enable);
783 canonicalize_rule_list_in_place(&mut self.extend_disable);
784 canonicalize_rule_list_in_place(&mut self.fixable);
785 canonicalize_rule_list_in_place(&mut self.unfixable);
786 }
787}
788
789/// Names of rumdl-native config files, searched in precedence order when
790/// walking up a directory tree.
791///
792/// This is the single source of truth for config discovery. Both the CLI
793/// (`SourcedConfig::discover_config_upward`, `discover_config_for_dir`) and
794/// the LSP (`RumdlLanguageServer::resolve_config_for_file`) must use this
795/// list; any deviation causes silent config-not-found bugs where the CLI
796/// recognises a config but the LSP does not (or vice versa).
797///
798/// See `src/lsp/tests.rs::test_lsp_cli_resolver_parity_on_fixtures` for
799/// the side-by-side resolver parity test that pins this invariant across
800/// several directory layouts.
801pub const RUMDL_CONFIG_FILES: &[&str] = &[".rumdl.toml", "rumdl.toml", ".config/rumdl.toml", "pyproject.toml"];
802
803pub const MARKDOWNLINT_CONFIG_FILES: &[&str] = &[
804 ".markdownlint-cli2.jsonc",
805 ".markdownlint-cli2.yaml",
806 ".markdownlint-cli2.yml",
807 ".markdownlint.json",
808 ".markdownlint.jsonc",
809 ".markdownlint.yaml",
810 ".markdownlint.yml",
811 "markdownlint.json",
812 "markdownlint.jsonc",
813 "markdownlint.yaml",
814 "markdownlint.yml",
815];
816
817/// Create a default configuration file at the specified path
818pub fn create_default_config(path: &str) -> Result<(), ConfigError> {
819 create_preset_config("default", path)
820}
821
822/// Create a configuration file with a specific style preset
823pub fn create_preset_config(preset: &str, path: &str) -> Result<(), ConfigError> {
824 if Path::new(path).exists() {
825 return Err(ConfigError::FileExists { path: path.to_string() });
826 }
827
828 let config_content = match preset {
829 "default" => generate_default_preset(),
830 "google" => generate_google_preset(),
831 "relaxed" => generate_relaxed_preset(),
832 _ => {
833 return Err(ConfigError::UnknownPreset {
834 name: preset.to_string(),
835 });
836 }
837 };
838
839 match fs::write(path, config_content) {
840 Ok(_) => Ok(()),
841 Err(err) => Err(ConfigError::IoError {
842 source: err,
843 path: path.to_string(),
844 }),
845 }
846}
847
848/// Generate the default preset configuration content.
849/// Returns the same content as `create_default_config`.
850fn generate_default_preset() -> String {
851 r#"# rumdl configuration file
852
853# Inherit settings from another config file (relative to this file's directory)
854# extends = "../base.rumdl.toml"
855
856# Global configuration options
857[global]
858# List of rules to disable (uncomment and modify as needed)
859# disable = ["MD013", "MD033"]
860
861# List of rules to enable exclusively (replaces defaults; only these rules will run)
862# enable = ["MD001", "MD003", "MD004"]
863
864# Additional rules to enable on top of defaults (additive, does not replace)
865# Use this to activate opt-in rules like MD060, MD063, MD072, MD073, MD074
866# extend-enable = ["MD060", "MD063"]
867
868# Additional rules to disable on top of the disable list (additive)
869# extend-disable = ["MD041"]
870
871# List of file/directory patterns to include for linting (if provided, only these will be linted)
872# include = [
873# "docs/*.md",
874# "src/**/*.md",
875# "README.md"
876# ]
877
878# List of file/directory patterns to exclude from linting
879exclude = [
880 # Common directories to exclude
881 ".git",
882 ".github",
883 "node_modules",
884 "vendor",
885 "dist",
886 "build",
887
888 # Specific files or patterns
889 "CHANGELOG.md",
890 "LICENSE.md",
891]
892
893# Respect .gitignore files when scanning directories (default: true)
894respect-gitignore = true
895
896# Markdown flavor/dialect (uncomment to enable)
897# Options: standard (default), gfm, commonmark, mkdocs, mdx, pandoc, quarto, obsidian, kramdown, azure_devops, myst
898# flavor = "mkdocs"
899
900# Rule-specific configurations (uncomment and modify as needed)
901
902# [MD003]
903# style = "atx" # Heading style (atx, atx_closed, setext)
904
905# [MD004]
906# style = "asterisk" # Unordered list style (asterisk, plus, dash, consistent)
907
908# [MD007]
909# indent = 4 # Unordered list indentation
910
911# [MD013]
912# line-length = 100 # Line length
913# code-blocks = false # Exclude code blocks from line length check
914# tables = false # Exclude tables from line length check
915# headings = true # Include headings in line length check
916
917# [MD044]
918# names = ["rumdl", "Markdown", "GitHub"] # Proper names that should be capitalized correctly
919# code-blocks = false # Check code blocks for proper names (default: false, skips code blocks)
920"#
921 .to_string()
922}
923
924/// Generate Google developer documentation style preset.
925/// Based on https://google.github.io/styleguide/docguide/style.html
926fn generate_google_preset() -> String {
927 r#"# rumdl configuration - Google developer documentation style
928# Based on https://google.github.io/styleguide/docguide/style.html
929
930[global]
931exclude = [
932 ".git",
933 ".github",
934 "node_modules",
935 "vendor",
936 "dist",
937 "build",
938 "CHANGELOG.md",
939 "LICENSE.md",
940]
941respect-gitignore = true
942
943# ATX-style headings required
944[MD003]
945style = "atx"
946
947# Unordered list style: dash
948[MD004]
949style = "dash"
950
951# 4-space indent for nested lists
952[MD007]
953indent = 4
954
955# Strict mode: no trailing spaces allowed (Google uses backslash for line breaks)
956[MD009]
957strict = true
958
959# 80-character line length
960[MD013]
961line-length = 80
962code-blocks = false
963tables = false
964
965# No trailing punctuation in headings
966[MD026]
967punctuation = ".,;:!。,;:!"
968
969# Fenced code blocks only (no indented code blocks)
970[MD046]
971style = "fenced"
972
973# Emphasis with underscores
974[MD049]
975style = "underscore"
976
977# Strong with asterisks
978[MD050]
979style = "asterisk"
980"#
981 .to_string()
982}
983
984/// Generate relaxed preset for existing projects adopting rumdl incrementally.
985/// Longer line lengths, fewer rules, lenient settings to minimize initial warnings.
986fn generate_relaxed_preset() -> String {
987 r#"# rumdl configuration - Relaxed preset
988# Lenient settings for existing projects adopting rumdl incrementally.
989# Minimizes initial warnings while still catching important issues.
990
991[global]
992exclude = [
993 ".git",
994 ".github",
995 "node_modules",
996 "vendor",
997 "dist",
998 "build",
999 "CHANGELOG.md",
1000 "LICENSE.md",
1001]
1002respect-gitignore = true
1003
1004# Disable rules that produce the most noise on existing projects
1005disable = [
1006 "MD013", # Line length - most existing files exceed 80 chars
1007 "MD033", # Inline HTML - commonly used in real-world markdown
1008 "MD041", # First line heading - not all files need it
1009]
1010
1011# Consistent heading style (any style, just be consistent)
1012[MD003]
1013style = "consistent"
1014
1015# Consistent list style
1016[MD004]
1017style = "consistent"
1018
1019# Consistent emphasis style
1020[MD049]
1021style = "consistent"
1022
1023# Consistent strong style
1024[MD050]
1025style = "consistent"
1026"#
1027 .to_string()
1028}
1029
1030/// How a config file being loaded was reached, which decides how much of it may
1031/// appear in a message about it.
1032///
1033/// A `Direct` file is one the user named: discovered in their project, or given
1034/// on the command line. Naming its path and quoting the line a parse error
1035/// points at tells them nothing they could not already read.
1036///
1037/// An `Extends` file was reached by following an `extends` value, and two things
1038/// about it are not the user's to see. Its path is built by substituting
1039/// environment variables, so printing the path prints their values wherever the
1040/// error goes, which under CI is the build log. And `extends` names an arbitrary
1041/// path chosen by whichever config declared it, so the file need not be config
1042/// at all: a repository you merely cloned can point rumdl at a private key and
1043/// have the parse error quote a line of it. Such a file is named by the
1044/// `extends` value as written, and its text is never quoted back.
1045///
1046/// This governs errors and warnings, which any lint run can emit unasked and
1047/// which therefore reach a build log that had no reason to hold a variable's
1048/// value. Output that answers a question about the configuration itself still
1049/// shows resolved paths, because that is the answer: `rumdl config` reports which
1050/// files took effect, a language server reports the same thing to the editor that
1051/// started it, and debug logging is how an `extends` chain gets diagnosed at all.
1052/// The first two read [`crate::config::SourcedConfig::loaded_files`], which is a
1053/// record of what loaded rather than a message about a problem, and it holds
1054/// resolved paths deliberately.
1055#[derive(Debug, Clone, Copy)]
1056pub(crate) enum ConfigOrigin<'a> {
1057 Direct,
1058 Extends {
1059 /// The reference together with the config that declared it. This is how
1060 /// the file is named by a message reporting a problem with the file
1061 /// itself, where knowing which config reached for it is what makes the
1062 /// problem diagnosable.
1063 described_as: &'a str,
1064 /// The reference alone, for the places a message only needs to identify
1065 /// the file: listing an `extends` chain, or naming it as the config that
1066 /// declared a further `extends`. Repeating the full form at every link
1067 /// nests one description inside the next.
1068 short_name: &'a str,
1069 },
1070}
1071
1072/// Stands in for text that came out of a file reached through `extends`.
1073///
1074/// Public because the file walk raises one of these messages itself: an `include`
1075/// pattern is judged where it is used rather than where it is parsed.
1076pub const WITHHELD: &str = "<withheld>";
1077
1078impl ConfigOrigin<'_> {
1079 /// How to name the file this origin describes in a message about the file.
1080 pub(crate) fn display_name(&self, path: &str) -> String {
1081 match self {
1082 Self::Direct => crate::config::validation::to_relative_display_path(path),
1083 Self::Extends { described_as, .. } => (*described_as).to_string(),
1084 }
1085 }
1086
1087 /// How to name this file when a message is about something else and only has
1088 /// to say which file it means.
1089 pub(crate) fn short_name(&self, path: &str) -> String {
1090 match self {
1091 Self::Direct => crate::config::validation::to_relative_display_path(path),
1092 Self::Extends { short_name, .. } => (*short_name).to_string(),
1093 }
1094 }
1095
1096 /// Whether a message about this file may quote the file's own text.
1097 pub(crate) fn may_quote_contents(&self) -> bool {
1098 matches!(self, Self::Direct)
1099 }
1100
1101 /// A piece of text read from this file, as a message about the file may
1102 /// show it.
1103 ///
1104 /// A `Direct` file's text is shown as written. An `Extends` target's is
1105 /// replaced by [`WITHHELD`], because an unrecognized key or an invalid value
1106 /// is still text out of a file rumdl was merely pointed at. The message
1107 /// keeps saying what kind of problem it found and which file holds it, and
1108 /// leaves the rest to whoever can open that file.
1109 pub(crate) fn quote<'t>(&self, text: &'t str) -> &'t str {
1110 if self.may_quote_contents() { text } else { WITHHELD }
1111 }
1112}
1113
1114/// A config file as a message about it refers to it: the name to use, and
1115/// whether the file's own text may be shown alongside.
1116///
1117/// Pairs the display name, worked out once when a file is parsed, with the
1118/// [`ConfigOrigin`] that decided it. Every warning a parser emits names the file
1119/// and asks the same question about its contents, so both travel together rather
1120/// than as two parameters that could drift apart.
1121#[derive(Debug, Clone, Copy)]
1122pub(crate) struct ConfigRef<'a> {
1123 name: &'a str,
1124 origin: ConfigOrigin<'a>,
1125}
1126
1127impl<'a> ConfigRef<'a> {
1128 pub(crate) fn new(name: &'a str, origin: ConfigOrigin<'a>) -> Self {
1129 Self { name, origin }
1130 }
1131
1132 /// See [`ConfigOrigin::quote`].
1133 pub(crate) fn quote<'t>(&self, text: &'t str) -> &'t str {
1134 self.origin.quote(text)
1135 }
1136
1137 /// See [`ConfigOrigin::may_quote_contents`].
1138 pub(crate) fn may_quote_contents(&self) -> bool {
1139 self.origin.may_quote_contents()
1140 }
1141}
1142
1143/// Displays as the file's name, so a message can name the file by interpolating
1144/// the reference itself.
1145impl std::fmt::Display for ConfigRef<'_> {
1146 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1147 f.write_str(self.name)
1148 }
1149}
1150
1151/// Errors that can occur when loading configuration
1152#[derive(Debug, thiserror::Error)]
1153pub enum ConfigError {
1154 /// Failed to read the configuration file
1155 #[error("Failed to read config file at {path}: {source}")]
1156 IoError { source: io::Error, path: String },
1157
1158 /// Failed to parse the configuration content (TOML or JSON)
1159 #[error("Failed to parse config: {0}")]
1160 ParseError(String),
1161
1162 /// Configuration file already exists
1163 #[error("Configuration file already exists at {path}")]
1164 FileExists { path: String },
1165
1166 /// Circular extends reference detected
1167 #[error("Circular extends reference: {path} already in chain {chain:?}")]
1168 CircularExtends { path: String, chain: Vec<String> },
1169
1170 /// Extends chain exceeds maximum depth
1171 #[error("Extends chain exceeds maximum depth of {max_depth} at {path}")]
1172 ExtendsDepthExceeded { path: String, max_depth: usize },
1173
1174 /// Extends target file not found
1175 #[error("extends target not found: {path} (referenced from {from})")]
1176 ExtendsNotFound { path: String, from: String },
1177
1178 /// An `extends` path referenced an environment variable that is not set
1179 #[error("extends path references undefined environment variable {var} (referenced from {from})")]
1180 ExtendsUndefinedVar {
1181 /// The variable as the message may name it: `$NAME`, or [`WITHHELD`] when
1182 /// the file that wrote it was itself reached through `extends` and its
1183 /// text may not be repeated (see [`ConfigOrigin`]).
1184 var: String,
1185 from: String,
1186 },
1187
1188 /// Unknown preset name
1189 #[error("Unknown preset: {name}. Valid presets: default, google, relaxed")]
1190 UnknownPreset { name: String },
1191}
1192
1193/// Why a config file found by discovery could not be loaded.
1194///
1195/// The two cases call for different handling. A discovered project file that
1196/// cannot be parsed is local to one project and a caller may reasonably skip it
1197/// and keep looking. A broken user config is machine-wide: every project that
1198/// merges onto it resolves to something other than what the user configured, so
1199/// it has to be surfaced instead of worked around.
1200#[derive(Debug, thiserror::Error)]
1201pub enum DiscoveredConfigError {
1202 /// The discovered file itself is unusable.
1203 #[error(transparent)]
1204 ProjectConfig(ConfigError),
1205
1206 /// The discovered file is fine, but the user config it merges on top of is
1207 /// unusable. Only a discovered markdownlint config has such a base.
1208 #[error(transparent)]
1209 UserConfig(ConfigError),
1210}
1211
1212impl From<DiscoveredConfigError> for ConfigError {
1213 fn from(error: DiscoveredConfigError) -> Self {
1214 match error {
1215 DiscoveredConfigError::ProjectConfig(error) | DiscoveredConfigError::UserConfig(error) => error,
1216 }
1217 }
1218}
1219
1220/// Get a rule-specific configuration value
1221/// Automatically tries both the original key and normalized variants (kebab-case ↔ snake_case)
1222/// for better markdownlint compatibility
1223pub fn get_rule_config_value<T: serde::de::DeserializeOwned>(config: &Config, rule_name: &str, key: &str) -> Option<T> {
1224 let norm_rule_name = rule_name.to_ascii_uppercase(); // Use uppercase for lookup
1225
1226 let rule_config = config.rules.get(&norm_rule_name)?;
1227
1228 // Try multiple key variants to support both underscore and kebab-case formats
1229 let key_variants = [
1230 key.to_string(), // Original key as provided
1231 normalize_key(key), // Normalized key (lowercase, kebab-case)
1232 key.replace('-', "_"), // Convert kebab-case to snake_case
1233 key.replace('_', "-"), // Convert snake_case to kebab-case
1234 ];
1235
1236 // Try each variant until we find a match
1237 for variant in &key_variants {
1238 if let Some(value) = rule_config.values.get(variant)
1239 && let Ok(result) = T::deserialize(value.clone())
1240 {
1241 return Some(result);
1242 }
1243 }
1244
1245 None
1246}
1247
1248/// Generate preset configuration for pyproject.toml format.
1249/// Converts the .rumdl.toml preset to pyproject.toml section format.
1250pub fn generate_pyproject_preset_config(preset: &str) -> Result<String, ConfigError> {
1251 match preset {
1252 "default" => Ok(generate_pyproject_config()),
1253 other => {
1254 let rumdl_config = match other {
1255 "google" => generate_google_preset(),
1256 "relaxed" => generate_relaxed_preset(),
1257 _ => {
1258 return Err(ConfigError::UnknownPreset {
1259 name: other.to_string(),
1260 });
1261 }
1262 };
1263 Ok(convert_rumdl_to_pyproject(&rumdl_config))
1264 }
1265 }
1266}
1267
1268/// Convert a .rumdl.toml config string to pyproject.toml format.
1269/// Rewrites `[global]` → `[tool.rumdl]` and `[MDXXX]` → `[tool.rumdl.MDXXX]`.
1270fn convert_rumdl_to_pyproject(rumdl_config: &str) -> String {
1271 let mut output = String::with_capacity(rumdl_config.len() + 128);
1272 for line in rumdl_config.lines() {
1273 let trimmed = line.trim();
1274 if trimmed.starts_with('[') && trimmed.ends_with(']') && !trimmed.starts_with("# [") {
1275 let section = &trimmed[1..trimmed.len() - 1];
1276 if section == "global" {
1277 output.push_str("[tool.rumdl]");
1278 } else {
1279 output.push_str(&format!("[tool.rumdl.{section}]"));
1280 }
1281 } else {
1282 output.push_str(line);
1283 }
1284 output.push('\n');
1285 }
1286 output
1287}
1288
1289/// Generate default rumdl configuration for pyproject.toml
1290pub fn generate_pyproject_config() -> String {
1291 let config_content = r#"
1292[tool.rumdl]
1293# Global configuration options
1294line-length = 100
1295disable = []
1296# extend-enable = ["MD060"] # Add opt-in rules (additive, keeps defaults)
1297# extend-disable = [] # Additional rules to disable (additive)
1298exclude = [
1299 # Common directories to exclude
1300 ".git",
1301 ".github",
1302 "node_modules",
1303 "vendor",
1304 "dist",
1305 "build",
1306]
1307respect-gitignore = true
1308
1309# Rule-specific configurations (uncomment and modify as needed)
1310
1311# [tool.rumdl.MD003]
1312# style = "atx" # Heading style (atx, atx_closed, setext)
1313
1314# [tool.rumdl.MD004]
1315# style = "asterisk" # Unordered list style (asterisk, plus, dash, consistent)
1316
1317# [tool.rumdl.MD007]
1318# indent = 4 # Unordered list indentation
1319
1320# [tool.rumdl.MD013]
1321# line-length = 100 # Line length
1322# code-blocks = false # Exclude code blocks from line length check
1323# tables = false # Exclude tables from line length check
1324# headings = true # Include headings in line length check
1325
1326# [tool.rumdl.MD044]
1327# names = ["rumdl", "Markdown", "GitHub"] # Proper names that should be capitalized correctly
1328# code-blocks = false # Check code blocks for proper names (default: false, skips code blocks)
1329"#;
1330
1331 config_content.to_string()
1332}