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