Skip to main content

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    /// Project root directory, used for resolving relative paths in per-file-ignores
86    #[serde(skip)]
87    pub project_root: Option<std::path::PathBuf>,
88
89    #[serde(skip)]
90    #[schemars(skip)]
91    pub(super) per_file_ignores_cache: Arc<OnceLock<PerFileIgnoreCache>>,
92
93    #[serde(skip)]
94    #[schemars(skip)]
95    pub(super) per_file_flavor_cache: Arc<OnceLock<PerFileFlavorCache>>,
96
97    /// Lazily-computed canonical form of `project_root`.
98    ///
99    /// `normalize_match_path` needs the canonical project root to strip
100    /// prefixes from absolute file paths. Without this cache, every per-file
101    /// lookup would re-canonicalize the project root (one syscall per file).
102    ///
103    /// ## Invariants
104    ///
105    /// - **Single-shot**: computed once on first use of [`Config::canonical_project_root`].
106    /// - **Never invalidated**: callers must not mutate `project_root` after
107    ///   the first call. `Config` is treated as immutable post-construction
108    ///   (the same assumption as `per_file_ignores_cache` and `per_file_flavor_cache`).
109    /// - **Construction-time existence**: the cache stores `None` if
110    ///   `project_root` is unset, missing on disk, or otherwise can't be
111    ///   canonicalized. In practice `project_root` is set after walking up to
112    ///   `.git`, so the directory always exists at the time the cache is first
113    ///   read; if a caller sets `project_root` to a not-yet-existing path,
114    ///   the cache will permanently store `None`.
115    /// - **`Arc` wrapping**: `Config` derives `Clone`, and clones share the
116    ///   same `OnceLock` so a value computed by one clone is observable to all.
117    ///
118    /// `cwd` deliberately is NOT cached symmetrically: callers read it fresh
119    /// from `std::env::current_dir()` per call because tests (and embedding
120    /// hosts like LSP servers) may legitimately mutate the process cwd
121    /// between lookups.
122    #[serde(skip)]
123    #[schemars(skip)]
124    pub(super) canonical_project_root_cache: Arc<OnceLock<Option<PathBuf>>>,
125}
126
127impl PartialEq for Config {
128    fn eq(&self, other: &Self) -> bool {
129        self.global == other.global
130            && self.per_file_ignores == other.per_file_ignores
131            && self.per_file_flavor == other.per_file_flavor
132            && self.code_block_tools == other.code_block_tools
133            && self.rules == other.rules
134            && self.project_root == other.project_root
135    }
136}
137
138#[derive(Debug)]
139pub(super) struct PerFileIgnoreCache {
140    globset: GlobSet,
141    rules: Vec<Vec<String>>,
142    /// Whether any pattern is absolute, i.e. whether matching has to consider
143    /// the file's absolute path as well as its project-relative form.
144    has_absolute: bool,
145}
146
147#[derive(Debug)]
148pub(super) struct PerFileFlavorCache {
149    matchers: Vec<(GlobMatcher, MarkdownFlavor)>,
150    /// See [`PerFileIgnoreCache::has_absolute`].
151    has_absolute: bool,
152}
153
154/// The file's absolute path, for matching against absolute patterns. `None`
155/// when the caller has no absolute pattern to match (the common case, which
156/// must not pay for the canonicalization) or when the file cannot be resolved.
157fn absolute_match_path(file_path: &Path, has_absolute: bool) -> Option<PathBuf> {
158    if !has_absolute {
159        return None;
160    }
161    crate::discovery::canonicalize_for_matching(file_path)
162}
163
164impl Config {
165    /// Check if the Markdown flavor is set to MkDocs
166    pub fn is_mkdocs_flavor(&self) -> bool {
167        self.global.flavor == MarkdownFlavor::MkDocs
168    }
169
170    // Future methods for when GFM and CommonMark are implemented:
171    // pub fn is_gfm_flavor(&self) -> bool
172    // pub fn is_commonmark_flavor(&self) -> bool
173
174    /// Get the configured Markdown flavor
175    pub fn markdown_flavor(&self) -> MarkdownFlavor {
176        self.global.flavor
177    }
178
179    /// Legacy method for backwards compatibility - redirects to is_mkdocs_flavor
180    pub fn is_mkdocs_project(&self) -> bool {
181        self.is_mkdocs_flavor()
182    }
183
184    /// Apply per-rule `enabled` config to the global enable/disable lists.
185    ///
186    /// For `[MD060] enabled = true`: adds the rule to `extend_enable` and
187    /// removes it from `disable` and `extend_disable`, ensuring the rule is active.
188    ///
189    /// For `[MD041] enabled = false`: adds the rule to `disable` and
190    /// removes it from `extend_enable`, ensuring the rule is inactive.
191    ///
192    /// Per-rule `enabled` takes precedence over global lists when there
193    /// is a conflict, since it represents a more specific intent.
194    pub fn apply_per_rule_enabled(&mut self) {
195        let mut to_enable: Vec<String> = Vec::new();
196        let mut to_disable: Vec<String> = Vec::new();
197
198        for (name, cfg) in &self.rules {
199            match cfg.values.get("enabled") {
200                Some(toml::Value::Boolean(true)) => {
201                    to_enable.push(name.clone());
202                }
203                Some(toml::Value::Boolean(false)) => {
204                    to_disable.push(name.clone());
205                }
206                _ => {}
207            }
208        }
209
210        for name in to_enable {
211            if !self.global.extend_enable.contains(&name) {
212                self.global.extend_enable.push(name.clone());
213            }
214            self.global.disable.retain(|n| n != &name);
215            self.global.extend_disable.retain(|n| n != &name);
216        }
217
218        for name in to_disable {
219            if !self.global.disable.contains(&name) {
220                self.global.disable.push(name.clone());
221            }
222            self.global.extend_enable.retain(|n| n != &name);
223        }
224    }
225
226    /// Get the severity override for a specific rule, if configured
227    pub fn get_rule_severity(&self, rule_name: &str) -> Option<crate::rule::Severity> {
228        self.rules.get(rule_name).and_then(|r| r.severity)
229    }
230
231    /// Return the canonical form of `project_root`, computed once and cached.
232    ///
233    /// Returns `None` if `project_root` is unset, doesn't exist on disk, or
234    /// otherwise cannot be canonicalized. Subsequent calls reuse the cached
235    /// value, eliminating the per-file `canonicalize()` syscall that
236    /// `normalize_match_path` would otherwise perform.
237    pub(super) fn canonical_project_root(&self) -> Option<&Path> {
238        self.canonical_project_root_cache
239            .get_or_init(|| self.project_root.as_deref().and_then(|p| p.canonicalize().ok()))
240            .as_deref()
241    }
242
243    /// Get the set of rules that should be ignored for a specific file based on per-file-ignores configuration
244    /// Returns a HashSet of rule names (uppercase, e.g., "MD033") that match the given file path
245    pub fn get_ignored_rules_for_file(&self, file_path: &Path) -> HashSet<String> {
246        let mut ignored_rules = HashSet::new();
247
248        if self.per_file_ignores.is_empty() {
249            return ignored_rules;
250        }
251
252        let cwd = std::env::current_dir().ok();
253        let path_for_matching = normalize_match_path(file_path, self.canonical_project_root(), cwd.as_deref());
254
255        let cache = self
256            .per_file_ignores_cache
257            .get_or_init(|| PerFileIgnoreCache::new(&self.per_file_ignores));
258
259        // Match the file path against all patterns, by its project-relative
260        // form and - for absolute patterns, including expanded `~` ones - by
261        // its absolute path.
262        let absolute = absolute_match_path(file_path, cache.has_absolute);
263        let matches = cache
264            .globset
265            .matches(path_for_matching.as_ref())
266            .into_iter()
267            .chain(absolute.iter().flat_map(|abs| cache.globset.matches(abs)));
268
269        for match_idx in matches {
270            if let Some(rules) = cache.rules.get(match_idx) {
271                for rule in rules {
272                    // Normalize rule names to uppercase (MD033, md033 -> MD033)
273                    ignored_rules.insert(rule.clone());
274                }
275            }
276        }
277
278        ignored_rules
279    }
280
281    /// Get the MarkdownFlavor for a specific file based on per-file-flavor configuration.
282    /// Returns the first matching pattern's flavor, or falls back to global flavor,
283    /// or auto-detects from extension, or defaults to Standard.
284    pub fn get_flavor_for_file(&self, file_path: &Path) -> MarkdownFlavor {
285        // If no per-file patterns, use fallback logic
286        if self.per_file_flavor.is_empty() {
287            return self.resolve_flavor_fallback(file_path);
288        }
289
290        let cwd = std::env::current_dir().ok();
291        let path_for_matching = normalize_match_path(file_path, self.canonical_project_root(), cwd.as_deref());
292
293        let cache = self
294            .per_file_flavor_cache
295            .get_or_init(|| PerFileFlavorCache::new(&self.per_file_flavor));
296
297        // Iterate in config order and return first match (IndexMap preserves order).
298        // Each pattern sees the file's project-relative form and - for absolute
299        // patterns, including expanded `~` ones - its absolute path.
300        let absolute = absolute_match_path(file_path, cache.has_absolute);
301        for (matcher, flavor) in &cache.matchers {
302            if matcher.is_match(path_for_matching.as_ref())
303                || absolute.as_ref().is_some_and(|abs| matcher.is_match(abs))
304            {
305                return *flavor;
306            }
307        }
308
309        // No pattern matched, use fallback
310        self.resolve_flavor_fallback(file_path)
311    }
312
313    /// Fallback flavor resolution: global flavor → auto-detect → Standard
314    fn resolve_flavor_fallback(&self, file_path: &Path) -> MarkdownFlavor {
315        // If global flavor is explicitly set to non-Standard, use it
316        if self.global.flavor != MarkdownFlavor::Standard {
317            return self.global.flavor;
318        }
319        // Auto-detect from extension
320        MarkdownFlavor::from_path(file_path)
321    }
322
323    /// Canonicalize every rule-name list inside this `Config`.
324    ///
325    /// This is the single enforcement point for the runtime invariant:
326    /// **after a `Config` is fully built, every rule-name list contains
327    /// canonical rule IDs (`"MD033"`) — never aliases (`"no-inline-html"`).**
328    ///
329    /// The invariant lets every consumer (`rules::filter_rules`, the LSP,
330    /// WASM, fix coordinator, per-file-ignore lookups) match against
331    /// `Rule::name()` with simple string equality. Mutation boundaries
332    /// (`From<SourcedConfig> for Config`, LSP `apply_lsp_settings_*`, WASM
333    /// `to_config_with_warnings`) call this before handing the `Config` to
334    /// the linting pipeline.
335    ///
336    /// Covers `global.{enable,disable,extend_enable,extend_disable,fixable,unfixable}`
337    /// and the values of `per_file_ignores`. Idempotent.
338    pub fn canonicalize_rule_lists(&mut self) {
339        use super::registry::canonicalize_rule_list_in_place;
340        self.global.canonicalize_rule_lists();
341        for rules in self.per_file_ignores.values_mut() {
342            canonicalize_rule_list_in_place(rules);
343        }
344    }
345
346    /// Merge inline configuration overrides into a copy of this config
347    ///
348    /// This enables automatic inline config support - the engine can merge
349    /// inline overrides and recreate rules without any per-rule changes.
350    ///
351    /// Returns a new Config with the inline overrides merged in.
352    /// If there are no inline overrides, returns a clone of self.
353    pub fn merge_with_inline_config(&self, inline_config: &crate::inline_config::InlineConfig) -> Self {
354        let overrides = inline_config.get_all_rule_configs();
355        if overrides.is_empty() {
356            return self.clone();
357        }
358
359        let mut merged = self.clone();
360
361        for (rule_name, json_override) in overrides {
362            // Get or create the rule config entry
363            let rule_config = merged.rules.entry(rule_name.clone()).or_default();
364
365            // Merge JSON values into the rule's config
366            if let Some(obj) = json_override.as_object() {
367                for (key, value) in obj {
368                    // Normalize key to kebab-case for consistency
369                    let normalized_key = key.replace('_', "-");
370
371                    // Convert JSON value to TOML value
372                    if let Some(toml_value) = json_to_toml(value) {
373                        rule_config.values.insert(normalized_key, toml_value);
374                    }
375                }
376            }
377        }
378
379        merged
380    }
381}
382
383/// Normalize a file path for matching against a glob pattern from configuration.
384///
385/// Glob patterns in `per-file-ignores` and `per-file-flavor` are written relative
386/// to the project root (e.g. `docs/**/*.md`), and the underlying matcher uses
387/// `literal_separator(true)` so an absolute path like `/home/user/proj/docs/x.md`
388/// will not match `docs/**/*.md`. This helper produces the form the glob expects:
389///
390/// 1. **Relative path** → return as-is.
391/// 2. **Absolute path under `project_root`** → return path relative to `project_root`.
392/// 3. **Absolute path under `cwd`** → return path relative to `cwd`. This is the
393///    safety net for invocations where `project_root` could not be discovered
394///    (no `.git` upward, LSP/CLI calls outside a project) but the file still
395///    lives somewhere under the working directory.
396/// 4. **Anywhere else** → return the raw path. A relative glob simply won't
397///    match it, which is the desired outcome for files outside any known root.
398///
399/// All canonicalization failures degrade gracefully to step 4 so editor buffers
400/// and pre-creation paths still flow through without panicking.
401///
402/// `canonical_project_root` is expected to already be canonical (via
403/// `Config::canonical_project_root`). `cwd` is canonicalized internally on each
404/// call since it is read fresh from the environment per invocation.
405pub(super) fn normalize_match_path<'a>(
406    file_path: &'a Path,
407    canonical_project_root: Option<&Path>,
408    cwd: Option<&Path>,
409) -> std::borrow::Cow<'a, Path> {
410    use std::borrow::Cow;
411
412    if file_path.is_relative() {
413        return Cow::Borrowed(file_path);
414    }
415
416    let Ok(canonical_file) = file_path.canonicalize() else {
417        log::debug!(
418            "normalize_match_path: canonicalize failed for {}; returning raw path. \
419             Per-file glob patterns may not match (file may not yet exist on disk).",
420            file_path.display()
421        );
422        return Cow::Borrowed(file_path);
423    };
424
425    if let Some(root) = canonical_project_root
426        && let Ok(rel) = canonical_file.strip_prefix(root)
427    {
428        return Cow::Owned(rel.to_path_buf());
429    }
430
431    if let Some(working_dir) = cwd
432        && let Ok(canonical_cwd) = working_dir.canonicalize()
433        && let Ok(rel) = canonical_file.strip_prefix(&canonical_cwd)
434    {
435        return Cow::Owned(rel.to_path_buf());
436    }
437
438    // Surface the silent fallback once per process at warn level so users with
439    // per-file glob configs notice when their patterns can't match a file.
440    // Subsequent occurrences stay at debug to avoid log spam.
441    static SILENT_FALLBACK_WARNED: OnceLock<()> = OnceLock::new();
442    log::log!(
443        first_call_warn_else_debug(&SILENT_FALLBACK_WARNED),
444        "{}",
445        format_silent_fallback_message(file_path, canonical_project_root, cwd),
446    );
447    Cow::Borrowed(file_path)
448}
449
450/// Returns [`log::Level::Warn`] the first time it is called with a given
451/// `latch`, and [`log::Level::Debug`] on every subsequent call. The latch
452/// is consumed by the first caller via `OnceLock::set`; later callers
453/// observe the latch as already set and downgrade.
454///
455/// Used to flag a fallback condition once per process without flooding
456/// logs when the same condition recurs (e.g. once per linted file).
457pub(super) fn first_call_warn_else_debug(latch: &OnceLock<()>) -> log::Level {
458    if latch.set(()).is_ok() {
459        log::Level::Warn
460    } else {
461        log::Level::Debug
462    }
463}
464
465/// Format the diagnostic emitted when [`normalize_match_path`] cannot
466/// relativise `file_path` against either the project root or the current
467/// working directory. Extracted so the exact wording can be asserted in
468/// tests without capturing log output.
469pub(super) fn format_silent_fallback_message(
470    file_path: &Path,
471    canonical_project_root: Option<&Path>,
472    cwd: Option<&Path>,
473) -> String {
474    format!(
475        "Per-file glob patterns will not match {}: file is outside project_root ({}) and cwd ({})",
476        file_path.display(),
477        DisplayPathOrUnset(canonical_project_root),
478        DisplayPathOrUnset(cwd),
479    )
480}
481
482/// Display adapter for `Option<&Path>` that renders the path via
483/// [`Path::display`] when present, or the literal `<unset>` when absent.
484/// Angle brackets follow Rust's diagnostic convention (e.g. `<unknown>`)
485/// and avoid double-paren rendering when the surrounding format string
486/// already wraps the value in `(…)`.
487struct DisplayPathOrUnset<'a>(Option<&'a Path>);
488
489impl std::fmt::Display for DisplayPathOrUnset<'_> {
490    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
491        match self.0 {
492            Some(path) => std::fmt::Display::fmt(&path.display(), f),
493            None => f.write_str("<unset>"),
494        }
495    }
496}
497
498/// Convert a serde_json::Value to a toml::Value
499pub(super) fn json_to_toml(json: &serde_json::Value) -> Option<toml::Value> {
500    match json {
501        serde_json::Value::Null => None,
502        serde_json::Value::Bool(b) => Some(toml::Value::Boolean(*b)),
503        serde_json::Value::Number(n) => n
504            .as_i64()
505            .map(toml::Value::Integer)
506            .or_else(|| n.as_f64().map(toml::Value::Float)),
507        serde_json::Value::String(s) => Some(toml::Value::String(s.clone())),
508        serde_json::Value::Array(arr) => {
509            let toml_arr: Vec<toml::Value> = arr.iter().filter_map(json_to_toml).collect();
510            Some(toml::Value::Array(toml_arr))
511        }
512        serde_json::Value::Object(obj) => {
513            let mut table = toml::map::Map::new();
514            for (k, v) in obj {
515                if let Some(tv) = json_to_toml(v) {
516                    table.insert(k.clone(), tv);
517                }
518            }
519            Some(toml::Value::Table(table))
520        }
521    }
522}
523
524impl PerFileIgnoreCache {
525    fn new(per_file_ignores: &BTreeMap<String, Vec<String>>) -> Self {
526        let mut builder = GlobSetBuilder::new();
527        let mut rules = Vec::new();
528
529        let mut has_absolute = false;
530        for (pattern, rules_list) in per_file_ignores {
531            let pattern = crate::discovery::expand_home_prefix(pattern);
532            has_absolute |= crate::discovery::is_absolute_pattern(&pattern);
533            if let Ok(glob) = Glob::new(&pattern) {
534                builder.add(glob);
535                // Canonicalize defensively: callers should have run
536                // Config::canonicalize_rule_lists already, but per-file-ignores
537                // has reached this cache directly from a few code paths
538                // historically, so we re-canonicalize here to keep the cache
539                // sound regardless of caller discipline.
540                rules.push(
541                    rules_list
542                        .iter()
543                        .map(|rule| super::registry::resolve_rule_name(rule))
544                        .collect(),
545                );
546            } else {
547                log::warn!("Invalid glob pattern in per-file-ignores: {pattern}");
548            }
549        }
550
551        let globset = builder.build().unwrap_or_else(|e| {
552            log::error!("Failed to build globset for per-file-ignores: {e}");
553            GlobSetBuilder::new().build().unwrap()
554        });
555
556        Self {
557            globset,
558            rules,
559            has_absolute,
560        }
561    }
562}
563
564impl PerFileFlavorCache {
565    fn new(per_file_flavor: &IndexMap<String, MarkdownFlavor>) -> Self {
566        let mut matchers = Vec::new();
567
568        let mut has_absolute = false;
569        for (pattern, flavor) in per_file_flavor {
570            let pattern = crate::discovery::expand_home_prefix(pattern);
571            has_absolute |= crate::discovery::is_absolute_pattern(&pattern);
572            if let Ok(glob) = GlobBuilder::new(&pattern).literal_separator(true).build() {
573                matchers.push((glob.compile_matcher(), *flavor));
574            } else {
575                log::warn!("Invalid glob pattern in per-file-flavor: {pattern}");
576            }
577        }
578
579        Self { matchers, has_absolute }
580    }
581}
582
583/// Global configuration options
584#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, schemars::JsonSchema)]
585#[serde(default, rename_all = "kebab-case")]
586pub struct GlobalConfig {
587    /// Enabled rules
588    #[serde(default)]
589    pub enable: Vec<String>,
590
591    /// Disabled rules
592    #[serde(default)]
593    pub disable: Vec<String>,
594
595    /// Files to exclude. Glob patterns, relative to the project root; a
596    /// leading `~/` expands to the home directory and absolute paths are
597    /// matched as written.
598    #[serde(default)]
599    pub exclude: Vec<String>,
600
601    /// Files to include. Glob patterns, relative to the project root; a
602    /// leading `~/` expands to the home directory and absolute paths are
603    /// matched as written.
604    #[serde(default)]
605    pub include: Vec<String>,
606
607    /// Respect .gitignore files when scanning directories
608    #[serde(default = "default_respect_gitignore", alias = "respect_gitignore")]
609    pub respect_gitignore: bool,
610
611    /// Global line length setting (used by MD013 and other rules if not overridden)
612    #[serde(default, alias = "line_length")]
613    pub line_length: LineLength,
614
615    /// Output format for linting results (e.g., "text", "json", "pylint", etc.)
616    #[serde(skip_serializing_if = "Option::is_none", alias = "output_format")]
617    pub output_format: Option<String>,
618
619    /// Rules that are allowed to be fixed when --fix is used
620    /// If specified, only these rules will be fixed
621    #[serde(default)]
622    pub fixable: Vec<String>,
623
624    /// Rules that should never be fixed, even when --fix is used
625    /// Takes precedence over fixable
626    #[serde(default)]
627    pub unfixable: Vec<String>,
628
629    /// Markdown flavor/dialect to use (mkdocs, gfm, commonmark, etc.)
630    /// When set, adjusts parsing and validation rules for that specific Markdown variant
631    #[serde(default)]
632    pub flavor: MarkdownFlavor,
633
634    /// \[DEPRECATED\] Whether to enforce exclude patterns for explicitly passed paths.
635    /// This option is deprecated as of v0.0.156 and has no effect.
636    /// Exclude patterns are now always respected, even for explicitly provided files.
637    /// This prevents duplication between rumdl config and tool configs like pre-commit.
638    #[serde(default, alias = "force_exclude")]
639    #[deprecated(since = "0.0.156", note = "Exclude patterns are now always respected")]
640    pub force_exclude: bool,
641
642    /// Directory to store cache files (default: .rumdl_cache).
643    /// A leading `~/` expands to the home directory; a relative path resolves
644    /// against the project root.
645    /// Can also be set via --cache-dir CLI flag or RUMDL_CACHE_DIR environment variable
646    #[serde(default, alias = "cache_dir", skip_serializing_if = "Option::is_none")]
647    pub cache_dir: Option<String>,
648
649    /// Whether caching is enabled (default: true)
650    /// Can also be disabled via --no-cache CLI flag
651    #[serde(default = "default_true")]
652    pub cache: bool,
653
654    /// Additional rules to enable on top of the base set (additive)
655    #[serde(default, alias = "extend_enable")]
656    pub extend_enable: Vec<String>,
657
658    /// Additional rules to disable on top of the base set (additive)
659    #[serde(default, alias = "extend_disable")]
660    pub extend_disable: Vec<String>,
661
662    /// Whether the enable list was explicitly set (even if empty).
663    /// Used to distinguish "no enable list configured" from "enable list is empty"
664    /// (e.g., markdownlint `default: false` with no rules enabled).
665    #[serde(skip)]
666    pub enable_is_explicit: bool,
667}
668
669fn default_respect_gitignore() -> bool {
670    true
671}
672
673fn default_true() -> bool {
674    true
675}
676
677// Add the Default impl
678impl Default for GlobalConfig {
679    #[allow(deprecated)]
680    fn default() -> Self {
681        Self {
682            enable: Vec::new(),
683            disable: Vec::new(),
684            exclude: Vec::new(),
685            include: Vec::new(),
686            respect_gitignore: true,
687            line_length: LineLength::default(),
688            output_format: None,
689            fixable: Vec::new(),
690            unfixable: Vec::new(),
691            flavor: MarkdownFlavor::default(),
692            force_exclude: false,
693            cache_dir: None,
694            cache: true,
695            extend_enable: Vec::new(),
696            extend_disable: Vec::new(),
697            enable_is_explicit: false,
698        }
699    }
700}
701
702impl GlobalConfig {
703    /// Canonicalize every rule-name list in this `GlobalConfig`.
704    ///
705    /// Rewrites `enable`, `disable`, `extend_enable`, `extend_disable`, `fixable`,
706    /// and `unfixable` so that all entries are canonical rule IDs (`"MD033"`)
707    /// rather than aliases (`"no-inline-html"`). Duplicates are removed,
708    /// preserving first-occurrence order; the special `"all"` keyword is
709    /// preserved.
710    ///
711    /// This must be called by every code path that mutates a runtime
712    /// `Config`'s rule lists from external input (markdownlint configs,
713    /// `.rumdl.toml`, LSP `initializationOptions`, WASM bindings, etc.) so
714    /// that downstream consumers (`rules::filter_rules`, the LSP, WASM) can
715    /// match against `Rule::name()` with simple string equality.
716    pub fn canonicalize_rule_lists(&mut self) {
717        use super::registry::canonicalize_rule_list_in_place;
718        canonicalize_rule_list_in_place(&mut self.enable);
719        canonicalize_rule_list_in_place(&mut self.disable);
720        canonicalize_rule_list_in_place(&mut self.extend_enable);
721        canonicalize_rule_list_in_place(&mut self.extend_disable);
722        canonicalize_rule_list_in_place(&mut self.fixable);
723        canonicalize_rule_list_in_place(&mut self.unfixable);
724    }
725}
726
727/// Names of rumdl-native config files, searched in precedence order when
728/// walking up a directory tree.
729///
730/// This is the single source of truth for config discovery. Both the CLI
731/// (`SourcedConfig::discover_config_upward`, `discover_config_for_dir`) and
732/// the LSP (`RumdlLanguageServer::resolve_config_for_file`) must use this
733/// list; any deviation causes silent config-not-found bugs where the CLI
734/// recognises a config but the LSP does not (or vice versa).
735///
736/// See `src/lsp/tests.rs::test_lsp_cli_resolver_parity_on_fixtures` for
737/// the side-by-side resolver parity test that pins this invariant across
738/// several directory layouts.
739pub const RUMDL_CONFIG_FILES: &[&str] = &[".rumdl.toml", "rumdl.toml", ".config/rumdl.toml", "pyproject.toml"];
740
741pub const MARKDOWNLINT_CONFIG_FILES: &[&str] = &[
742    ".markdownlint-cli2.jsonc",
743    ".markdownlint-cli2.yaml",
744    ".markdownlint-cli2.yml",
745    ".markdownlint.json",
746    ".markdownlint.jsonc",
747    ".markdownlint.yaml",
748    ".markdownlint.yml",
749    "markdownlint.json",
750    "markdownlint.jsonc",
751    "markdownlint.yaml",
752    "markdownlint.yml",
753];
754
755/// Create a default configuration file at the specified path
756pub fn create_default_config(path: &str) -> Result<(), ConfigError> {
757    create_preset_config("default", path)
758}
759
760/// Create a configuration file with a specific style preset
761pub fn create_preset_config(preset: &str, path: &str) -> Result<(), ConfigError> {
762    if Path::new(path).exists() {
763        return Err(ConfigError::FileExists { path: path.to_string() });
764    }
765
766    let config_content = match preset {
767        "default" => generate_default_preset(),
768        "google" => generate_google_preset(),
769        "relaxed" => generate_relaxed_preset(),
770        _ => {
771            return Err(ConfigError::UnknownPreset {
772                name: preset.to_string(),
773            });
774        }
775    };
776
777    match fs::write(path, config_content) {
778        Ok(_) => Ok(()),
779        Err(err) => Err(ConfigError::IoError {
780            source: err,
781            path: path.to_string(),
782        }),
783    }
784}
785
786/// Generate the default preset configuration content.
787/// Returns the same content as `create_default_config`.
788fn generate_default_preset() -> String {
789    r#"# rumdl configuration file
790
791# Inherit settings from another config file (relative to this file's directory)
792# extends = "../base.rumdl.toml"
793
794# Global configuration options
795[global]
796# List of rules to disable (uncomment and modify as needed)
797# disable = ["MD013", "MD033"]
798
799# List of rules to enable exclusively (replaces defaults; only these rules will run)
800# enable = ["MD001", "MD003", "MD004"]
801
802# Additional rules to enable on top of defaults (additive, does not replace)
803# Use this to activate opt-in rules like MD060, MD063, MD072, MD073, MD074
804# extend-enable = ["MD060", "MD063"]
805
806# Additional rules to disable on top of the disable list (additive)
807# extend-disable = ["MD041"]
808
809# List of file/directory patterns to include for linting (if provided, only these will be linted)
810# include = [
811#    "docs/*.md",
812#    "src/**/*.md",
813#    "README.md"
814# ]
815
816# List of file/directory patterns to exclude from linting
817exclude = [
818    # Common directories to exclude
819    ".git",
820    ".github",
821    "node_modules",
822    "vendor",
823    "dist",
824    "build",
825
826    # Specific files or patterns
827    "CHANGELOG.md",
828    "LICENSE.md",
829]
830
831# Respect .gitignore files when scanning directories (default: true)
832respect-gitignore = true
833
834# Markdown flavor/dialect (uncomment to enable)
835# Options: standard (default), gfm, commonmark, mkdocs, mdx, pandoc, quarto, obsidian, kramdown, azure_devops, myst
836# flavor = "mkdocs"
837
838# Rule-specific configurations (uncomment and modify as needed)
839
840# [MD003]
841# style = "atx"  # Heading style (atx, atx_closed, setext)
842
843# [MD004]
844# style = "asterisk"  # Unordered list style (asterisk, plus, dash, consistent)
845
846# [MD007]
847# indent = 4  # Unordered list indentation
848
849# [MD013]
850# line-length = 100  # Line length
851# code-blocks = false  # Exclude code blocks from line length check
852# tables = false  # Exclude tables from line length check
853# headings = true  # Include headings in line length check
854
855# [MD044]
856# names = ["rumdl", "Markdown", "GitHub"]  # Proper names that should be capitalized correctly
857# code-blocks = false  # Check code blocks for proper names (default: false, skips code blocks)
858"#
859    .to_string()
860}
861
862/// Generate Google developer documentation style preset.
863/// Based on https://google.github.io/styleguide/docguide/style.html
864fn generate_google_preset() -> String {
865    r#"# rumdl configuration - Google developer documentation style
866# Based on https://google.github.io/styleguide/docguide/style.html
867
868[global]
869exclude = [
870    ".git",
871    ".github",
872    "node_modules",
873    "vendor",
874    "dist",
875    "build",
876    "CHANGELOG.md",
877    "LICENSE.md",
878]
879respect-gitignore = true
880
881# ATX-style headings required
882[MD003]
883style = "atx"
884
885# Unordered list style: dash
886[MD004]
887style = "dash"
888
889# 4-space indent for nested lists
890[MD007]
891indent = 4
892
893# Strict mode: no trailing spaces allowed (Google uses backslash for line breaks)
894[MD009]
895strict = true
896
897# 80-character line length
898[MD013]
899line-length = 80
900code-blocks = false
901tables = false
902
903# No trailing punctuation in headings
904[MD026]
905punctuation = ".,;:!。,;:!"
906
907# Fenced code blocks only (no indented code blocks)
908[MD046]
909style = "fenced"
910
911# Emphasis with underscores
912[MD049]
913style = "underscore"
914
915# Strong with asterisks
916[MD050]
917style = "asterisk"
918"#
919    .to_string()
920}
921
922/// Generate relaxed preset for existing projects adopting rumdl incrementally.
923/// Longer line lengths, fewer rules, lenient settings to minimize initial warnings.
924fn generate_relaxed_preset() -> String {
925    r#"# rumdl configuration - Relaxed preset
926# Lenient settings for existing projects adopting rumdl incrementally.
927# Minimizes initial warnings while still catching important issues.
928
929[global]
930exclude = [
931    ".git",
932    ".github",
933    "node_modules",
934    "vendor",
935    "dist",
936    "build",
937    "CHANGELOG.md",
938    "LICENSE.md",
939]
940respect-gitignore = true
941
942# Disable rules that produce the most noise on existing projects
943disable = [
944    "MD013",  # Line length - most existing files exceed 80 chars
945    "MD033",  # Inline HTML - commonly used in real-world markdown
946    "MD041",  # First line heading - not all files need it
947]
948
949# Consistent heading style (any style, just be consistent)
950[MD003]
951style = "consistent"
952
953# Consistent list style
954[MD004]
955style = "consistent"
956
957# Consistent emphasis style
958[MD049]
959style = "consistent"
960
961# Consistent strong style
962[MD050]
963style = "consistent"
964"#
965    .to_string()
966}
967
968/// Errors that can occur when loading configuration
969#[derive(Debug, thiserror::Error)]
970pub enum ConfigError {
971    /// Failed to read the configuration file
972    #[error("Failed to read config file at {path}: {source}")]
973    IoError { source: io::Error, path: String },
974
975    /// Failed to parse the configuration content (TOML or JSON)
976    #[error("Failed to parse config: {0}")]
977    ParseError(String),
978
979    /// Configuration file already exists
980    #[error("Configuration file already exists at {path}")]
981    FileExists { path: String },
982
983    /// Circular extends reference detected
984    #[error("Circular extends reference: {path} already in chain {chain:?}")]
985    CircularExtends { path: String, chain: Vec<String> },
986
987    /// Extends chain exceeds maximum depth
988    #[error("Extends chain exceeds maximum depth of {max_depth} at {path}")]
989    ExtendsDepthExceeded { path: String, max_depth: usize },
990
991    /// Extends target file not found
992    #[error("extends target not found: {path} (referenced from {from})")]
993    ExtendsNotFound { path: String, from: String },
994
995    /// An `extends` path referenced an environment variable that is not set
996    #[error("extends path references undefined environment variable ${var} (referenced from {from})")]
997    ExtendsUndefinedVar { var: String, from: String },
998
999    /// Unknown preset name
1000    #[error("Unknown preset: {name}. Valid presets: default, google, relaxed")]
1001    UnknownPreset { name: String },
1002}
1003
1004/// Get a rule-specific configuration value
1005/// Automatically tries both the original key and normalized variants (kebab-case ↔ snake_case)
1006/// for better markdownlint compatibility
1007pub fn get_rule_config_value<T: serde::de::DeserializeOwned>(config: &Config, rule_name: &str, key: &str) -> Option<T> {
1008    let norm_rule_name = rule_name.to_ascii_uppercase(); // Use uppercase for lookup
1009
1010    let rule_config = config.rules.get(&norm_rule_name)?;
1011
1012    // Try multiple key variants to support both underscore and kebab-case formats
1013    let key_variants = [
1014        key.to_string(),       // Original key as provided
1015        normalize_key(key),    // Normalized key (lowercase, kebab-case)
1016        key.replace('-', "_"), // Convert kebab-case to snake_case
1017        key.replace('_', "-"), // Convert snake_case to kebab-case
1018    ];
1019
1020    // Try each variant until we find a match
1021    for variant in &key_variants {
1022        if let Some(value) = rule_config.values.get(variant)
1023            && let Ok(result) = T::deserialize(value.clone())
1024        {
1025            return Some(result);
1026        }
1027    }
1028
1029    None
1030}
1031
1032/// Generate preset configuration for pyproject.toml format.
1033/// Converts the .rumdl.toml preset to pyproject.toml section format.
1034pub fn generate_pyproject_preset_config(preset: &str) -> Result<String, ConfigError> {
1035    match preset {
1036        "default" => Ok(generate_pyproject_config()),
1037        other => {
1038            let rumdl_config = match other {
1039                "google" => generate_google_preset(),
1040                "relaxed" => generate_relaxed_preset(),
1041                _ => {
1042                    return Err(ConfigError::UnknownPreset {
1043                        name: other.to_string(),
1044                    });
1045                }
1046            };
1047            Ok(convert_rumdl_to_pyproject(&rumdl_config))
1048        }
1049    }
1050}
1051
1052/// Convert a .rumdl.toml config string to pyproject.toml format.
1053/// Rewrites `[global]` → `[tool.rumdl]` and `[MDXXX]` → `[tool.rumdl.MDXXX]`.
1054fn convert_rumdl_to_pyproject(rumdl_config: &str) -> String {
1055    let mut output = String::with_capacity(rumdl_config.len() + 128);
1056    for line in rumdl_config.lines() {
1057        let trimmed = line.trim();
1058        if trimmed.starts_with('[') && trimmed.ends_with(']') && !trimmed.starts_with("# [") {
1059            let section = &trimmed[1..trimmed.len() - 1];
1060            if section == "global" {
1061                output.push_str("[tool.rumdl]");
1062            } else {
1063                output.push_str(&format!("[tool.rumdl.{section}]"));
1064            }
1065        } else {
1066            output.push_str(line);
1067        }
1068        output.push('\n');
1069    }
1070    output
1071}
1072
1073/// Generate default rumdl configuration for pyproject.toml
1074pub fn generate_pyproject_config() -> String {
1075    let config_content = r#"
1076[tool.rumdl]
1077# Global configuration options
1078line-length = 100
1079disable = []
1080# extend-enable = ["MD060"]  # Add opt-in rules (additive, keeps defaults)
1081# extend-disable = []  # Additional rules to disable (additive)
1082exclude = [
1083    # Common directories to exclude
1084    ".git",
1085    ".github",
1086    "node_modules",
1087    "vendor",
1088    "dist",
1089    "build",
1090]
1091respect-gitignore = true
1092
1093# Rule-specific configurations (uncomment and modify as needed)
1094
1095# [tool.rumdl.MD003]
1096# style = "atx"  # Heading style (atx, atx_closed, setext)
1097
1098# [tool.rumdl.MD004]
1099# style = "asterisk"  # Unordered list style (asterisk, plus, dash, consistent)
1100
1101# [tool.rumdl.MD007]
1102# indent = 4  # Unordered list indentation
1103
1104# [tool.rumdl.MD013]
1105# line-length = 100  # Line length
1106# code-blocks = false  # Exclude code blocks from line length check
1107# tables = false  # Exclude tables from line length check
1108# headings = true  # Include headings in line length check
1109
1110# [tool.rumdl.MD044]
1111# names = ["rumdl", "Markdown", "GitHub"]  # Proper names that should be capitalized correctly
1112# code-blocks = false  # Check code blocks for proper names (default: false, skips code blocks)
1113"#;
1114
1115    config_content.to_string()
1116}