rumdl 0.2.60

A fast Markdown linter written in Rust (Ru(st) MarkDown Linter)
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
//! Per-directory configuration resolution.
//!
//! Groups files by their effective config, enabling subdirectory configs
//! to override the root config for files within their scope. This follows
//! the Ruff model: subdirectory configs are standalone by default, and
//! users can use `extends` for inheritance.

use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::path::{Path, PathBuf};
use std::sync::Arc;

use rumdl_lib::config as rumdl_config;
use rumdl_lib::config::editorconfig::{self, EditorConfigSettings, EditorConfigWarning};
use rumdl_lib::rule::Rule;

use crate::cache::LintCache;
use crate::file_processor::CacheHashes;

/// A group of files that share the same configuration.
pub struct ConfigGroup {
    pub config: rumdl_config::Config,
    pub rules: Vec<Box<dyn Rule>>,
    pub cache_hashes: Option<Arc<CacheHashes>>,
    pub files: Vec<String>,
}

/// The run's root configuration, in both the forms grouping needs.
///
/// `config` is what every file falls back to. `sourced` is the same
/// configuration with provenance intact, which `.editorconfig` layers into: it
/// can only fill in a setting no rumdl config mentions, and that distinction
/// exists solely in the sourced form.
pub struct RootConfig<'a> {
    pub config: &'a rumdl_config::Config,
    pub sourced: &'a rumdl_config::SourcedConfig<rumdl_config::ConfigValidated>,
}

/// The two roots that anchor config resolution for a run.
///
/// They coincide for the common case (a project root discovered below the cwd).
/// They diverge only for a multi-path run with no discovered project config: then
/// `grouping_root` is the common-ancestor anchor (so subdirectory configs are still
/// grouped) while `project_root` stays unset (so the cache dir, per-file globs and
/// displayed paths remain cwd-relative).
pub struct ResolutionRoots<'a> {
    /// Upper bound for the per-directory config walk.
    pub grouping_root: Option<&'a Path>,
    /// The run's project root; bases a discovered subdir config's per-file globs.
    pub project_root: Option<&'a Path>,
}

/// The directory a config file governs (its scope).
///
/// For `.rumdl.toml`, `rumdl.toml` and `pyproject.toml` this is the containing
/// directory. A `.config/rumdl.toml` config governs the directory that holds
/// `.config/`, not `.config/` itself, so its scope is the grandparent. Used to base
/// a discovered subdir config's per-file globs on the files it actually governs.
fn config_scope_dir(config_path: &Path) -> Option<&Path> {
    let parent = config_path.parent()?;
    if parent.file_name() == Some(std::ffi::OsStr::new(".config")) {
        parent.parent()
    } else {
        Some(parent)
    }
}

/// Check whether a config path is at a root-level location.
///
/// Root-level means the config lives directly in the project root
/// or in `project_root/.config/`. Both are considered the "root config"
/// and should not create a separate subdirectory group.
///
/// Paths are canonicalized before comparison so platform-specific
/// representations do not cause a false negative. On Windows the discovered
/// `config_path` is a canonical, long-name `\\?\` path while `project_root` may
/// be an 8.3 short name (e.g. `RUNNER~1`); on Unix symlinks can differ. A false
/// negative here misclassifies the root config as a subdirectory config and
/// reloads it without the inline `--config` overrides.
fn is_root_level_config(config_path: &Path, project_root: &Path) -> bool {
    let canon = |p: &Path| std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf());
    let Some(parent) = config_path.parent() else {
        return false;
    };
    let parent = canon(parent);
    // Direct child of project root: .rumdl.toml, rumdl.toml, pyproject.toml
    // or config in a `.config/` subdirectory: .config/rumdl.toml
    parent == canon(project_root) || parent == canon(&project_root.join(".config"))
}

/// Resolve files into config groups based on per-directory config discovery.
///
/// In auto-discovery mode, files in subdirectories that contain their own
/// config files will use that config instead of the root config.
///
/// Fast path: when discovery is bypassed (`bypass_discovery`, i.e. an explicit
/// `--config` or `--isolated`) or there is no grouping root, all files use the root
/// config (zero overhead).
///
/// `inline_overrides` are the inline `--config 'RULE.key=value'` overrides already
/// merged into `root_config`; they are re-applied on top of each discovered
/// subdirectory config so CLI precedence holds across every group, not just the root.
///
/// See [`ResolutionRoots`] for how the grouping root and project root relate.
pub fn resolve_config_groups(
    file_paths: &[String],
    root: &RootConfig<'_>,
    args: &crate::CheckArgs,
    roots: &ResolutionRoots<'_>,
    inline_overrides: &[toml::Table],
    cache: &Option<Arc<LintCache>>,
    bypass_discovery: bool,
) -> ResolvedGroups {
    let mut grouping = Grouping::default();

    // Fast path: discovery bypassed or no grouping root; all files use the root config
    if bypass_discovery || roots.grouping_root.is_none() {
        grouping.push_groups(root.sourced, root.config.clone(), file_paths.to_vec(), args, cache);
        return grouping.finish();
    }

    let grouping_root = roots.grouping_root.unwrap();

    // Cache: directory → Option<config file path>
    // None means "no subdirectory config found, use root"
    let mut dir_config_cache: HashMap<PathBuf, Option<PathBuf>> = HashMap::new();

    // Map each file to its effective config path.
    // BTreeMap ensures deterministic group ordering across runs.
    let mut file_config_map: BTreeMap<Option<PathBuf>, Vec<String>> = BTreeMap::new();

    for file_path in file_paths {
        let path = Path::new(file_path);
        let parent_dir = match path.parent() {
            Some(dir) if dir.is_dir() => dir.to_path_buf(),
            _ => grouping_root.to_path_buf(),
        };

        // Look up or discover the config for this directory
        let config_path = discover_with_cache(&parent_dir, grouping_root, &mut dir_config_cache);

        // Configs at the grouping root level use the already-loaded root config
        let effective_config = config_path.filter(|cp| !is_root_level_config(cp, grouping_root));

        file_config_map
            .entry(effective_config)
            .or_default()
            .push(file_path.clone());
    }

    for (config_path, files) in file_config_map {
        match config_path {
            None => {
                // Root config group
                grouping.push_groups(root.sourced, root.config.clone(), files, args, cache);
            }
            Some(path) => {
                // Subdirectory config group. Base its per-file globs on the real
                // project root, or on the directory the config governs when there is
                // none, never on the grouping anchor (which may sit above its scope).
                let subconfig_root = roots
                    .project_root
                    .or_else(|| config_scope_dir(&path))
                    .unwrap_or(grouping_root);
                match rumdl_config::SourcedConfig::load_sourced_for_path(&path, subconfig_root) {
                    Ok(mut sourced) => {
                        // Layer inline `--config` overrides on top at CLI precedence
                        // (as the global config does), then convert and apply the
                        // flavor / gitignore overrides that take effect everywhere.
                        crate::cli_config_override::apply_inline_overrides(&mut sourced, inline_overrides);
                        let sourced = sourced.into_validated_unchecked();
                        let mut subdir_config: rumdl_config::Config = sourced.clone().into();
                        apply_cli_config_overrides(&mut subdir_config, args);

                        grouping.push_groups(&sourced, subdir_config, files, args, cache);
                    }
                    Err(e) => {
                        // A subdirectory config that cannot be loaded is a configuration
                        // problem, so it counts whether or not it is printed: the files it
                        // governs are linted under settings their author did not write.
                        // Suppressed by --silent like every other config warning.
                        grouping.config_warning = true;
                        if !args.silent {
                            eprintln!(
                                "\x1b[33m[config warning]\x1b[0m Failed to load config {}: {}. Using root config for affected files.",
                                path.display(),
                                e
                            );
                        }

                        grouping.push_groups(root.sourced, root.config.clone(), files, args, cache);
                    }
                }
            }
        }
    }

    grouping.finish()
}

/// The config groups a run resolved to, and whether resolving them turned up a
/// configuration problem.
pub struct ResolvedGroups {
    pub groups: Vec<ConfigGroup>,
    /// Set when a subdirectory config failed to load, or an `.editorconfig`
    /// property was read but could not be applied. Reported like rumdl's own
    /// config warnings, and counted the same way by `--deny-config-warnings`.
    pub config_warning: bool,
}

/// The groups being built, plus the state that spans them.
#[derive(Default)]
struct Grouping {
    groups: Vec<ConfigGroup>,
    /// The `.editorconfig` messages already printed, so one covering many files
    /// is reported once for the run rather than once per group.
    reported: BTreeSet<String>,
    /// Whether resolving the groups turned up a configuration problem, which
    /// `--deny-config-warnings` fails the run on.
    config_warning: bool,
}

impl Grouping {
    fn finish(self) -> ResolvedGroups {
        ResolvedGroups {
            groups: self.groups,
            config_warning: self.config_warning,
        }
    }

    /// Build the config groups for a set of files that share one rumdl config.
    ///
    /// That is a single group unless the config opts into `.editorconfig`
    /// reading, in which case the files are sub-grouped by the properties
    /// resolved for each: section globs and nested `.editorconfig` files can
    /// give two files in the same directory different settings, so the grouping
    /// has to be per file even though the rules are instantiated per group.
    fn push_groups(
        &mut self,
        base: &rumdl_config::SourcedConfig<rumdl_config::ConfigValidated>,
        base_config: rumdl_config::Config,
        files: Vec<String>,
        args: &crate::CheckArgs,
        cache: &Option<Arc<LintCache>>,
    ) {
        if !base_config.global.editorconfig {
            self.groups.push(build_group(base_config, files, args, cache));
            return;
        }

        // Keyed by the resolved settings so files resolving identically share one
        // config, and by a `BTreeMap` so the group order is the same on every run.
        let mut by_settings: BTreeMap<(EditorConfigSettings, Option<String>), SettingsGroup> = BTreeMap::new();

        for file in files {
            let resolution = editorconfig::resolve(Path::new(&file));
            let group = by_settings.entry((resolution.settings, resolution.origin)).or_default();
            group
                .warnings
                .extend(resolution.warnings.into_iter().map(|warning| (file.clone(), warning)));
            group.files.push(file);
        }

        for ((settings, origin), group) in by_settings {
            let config = config_with_editorconfig(base, &base_config, &settings, origin.as_deref(), args);
            let built = build_group(config, group.files, args, cache);
            self.config_warning |=
                report_editorconfig_warnings(&group.warnings, &built.rules, &built.config, &mut self.reported, args);
            self.groups.push(built);
        }
    }
}

/// The files that resolved to one set of `.editorconfig` settings, along with the
/// warnings those resolutions raised.
///
/// Each warning keeps the file it came from: whether it is worth reporting
/// depends on the rules that run for that one file, and `per-file-ignores` can
/// make those differ from the ones its group-mates run.
#[derive(Default)]
struct SettingsGroup {
    files: Vec<String>,
    warnings: Vec<(String, EditorConfigWarning)>,
}

/// Layer a file's resolved `.editorconfig` settings onto the config it would
/// otherwise use.
fn config_with_editorconfig(
    base: &rumdl_config::SourcedConfig<rumdl_config::ConfigValidated>,
    base_config: &rumdl_config::Config,
    settings: &EditorConfigSettings,
    origin: Option<&str>,
    args: &crate::CheckArgs,
) -> rumdl_config::Config {
    if settings.is_empty() {
        return base_config.clone();
    }

    let mut sourced = base.clone();
    editorconfig::apply(&mut sourced, settings, origin);
    let mut config: rumdl_config::Config = sourced.into();
    apply_cli_config_overrides(&mut config, args);
    config
}

/// Whether the rule a warning names runs for the file that raised it: enabled by
/// that file's config, and left on for its path by `per-file-ignores`.
///
/// This is the decision `filter_rules_for_file` makes before linting, asked
/// without cloning a rule set that is only being consulted.
fn rule_runs_for(rule: &str, rules: &[Box<dyn Rule>], config: &rumdl_config::Config, path: &Path) -> bool {
    rules.iter().any(|candidate| candidate.name() == rule) && !config.get_ignored_rules_for_file(path).contains(rule)
}

/// Report the `.editorconfig` properties rumdl read but does not act on, and
/// answer whether any of them counts as a configuration problem.
///
/// A warning naming a rule is only true while that rule runs for the file that
/// raised it, so it is dropped otherwise. The rest count whether or not they are
/// printed, so `--silent` suppresses the output without changing what
/// `--deny-config-warnings` sees. `reported` carries the messages already
/// printed: one `.editorconfig` typically covers many files, and repeating a
/// message once per file would bury the lint output.
fn report_editorconfig_warnings(
    warnings: &[(String, EditorConfigWarning)],
    rules: &[Box<dyn Rule>],
    config: &rumdl_config::Config,
    reported: &mut BTreeSet<String>,
    args: &crate::CheckArgs,
) -> bool {
    let mut problem = false;
    for (file, warning) in warnings {
        if let Some(rule) = warning.rule
            && !rule_runs_for(rule, rules, config, Path::new(file))
        {
            continue;
        }
        problem = true;
        if !args.silent && reported.insert(warning.message.clone()) {
            eprintln!("\x1b[33m[config warning]\x1b[0m {}", warning.message);
        }
    }
    problem
}

/// The configuration content piped in on stdin is linted with.
pub struct StdinConfig {
    pub config: rumdl_config::Config,
    pub rules: Vec<Box<dyn Rule>>,
    /// Set when an `.editorconfig` property was read but could not be applied.
    pub config_warning: bool,
}

/// Resolve the configuration for content piped in on stdin.
///
/// `--stdin-filename` names a file in the project, and the rest of the stdin
/// path already treats it as one for per-file ignores and flavor, so the
/// `.editorconfig` that applies to that file applies to this content too.
/// Without a filename there is no file to resolve properties for. Per-directory
/// rumdl configs are deliberately not discovered here: that is the caller's
/// decision, unchanged by this.
pub fn resolve_stdin_config(root: &RootConfig<'_>, args: &crate::CheckArgs) -> StdinConfig {
    let file = args
        .stdin_filename
        .as_deref()
        .filter(|_| root.config.global.editorconfig);

    let Some(file) = file else {
        return StdinConfig {
            rules: crate::file_processor::get_enabled_rules_from_checkargs(args, root.config),
            config: root.config.clone(),
            config_warning: false,
        };
    };

    let resolution = editorconfig::resolve(Path::new(file));
    let config = config_with_editorconfig(
        root.sourced,
        root.config,
        &resolution.settings,
        resolution.origin.as_deref(),
        args,
    );
    let rules = crate::file_processor::get_enabled_rules_from_checkargs(args, &config);

    let warnings: Vec<(String, EditorConfigWarning)> = resolution
        .warnings
        .into_iter()
        .map(|warning| (file.to_string(), warning))
        .collect();
    let config_warning = report_editorconfig_warnings(&warnings, &rules, &config, &mut BTreeSet::new(), args);

    StdinConfig {
        config,
        rules,
        config_warning,
    }
}

/// Instantiate the rules and cache hashes a config implies.
fn build_group(
    config: rumdl_config::Config,
    files: Vec<String>,
    args: &crate::CheckArgs,
    cache: &Option<Arc<LintCache>>,
) -> ConfigGroup {
    let rules = crate::file_processor::get_enabled_rules_from_checkargs(args, &config);
    let cache_hashes = cache.as_ref().map(|_| Arc::new(CacheHashes::new(&config, &rules)));

    ConfigGroup {
        config,
        rules,
        cache_hashes,
        files,
    }
}

/// Discover the config file for a directory, using and populating the cache.
///
/// Also caches intermediate directories traversed during the upward walk
/// so that sibling files sharing a parent directory get cache hits.
fn discover_with_cache(
    dir: &Path,
    project_root: &Path,
    cache: &mut HashMap<PathBuf, Option<PathBuf>>,
) -> Option<PathBuf> {
    if let Some(cached) = cache.get(dir) {
        return cached.clone();
    }

    // Walk upward collecting directories we traverse, so we can cache them all
    let result = rumdl_config::SourcedConfig::discover_config_for_dir(dir, project_root);

    // Cache the result for this directory
    cache.insert(dir.to_path_buf(), result.clone());

    // Also cache intermediate directories between dir and the config location
    // (or project root if no config found). This prevents redundant walks.
    if let Some(ref config_path) = result {
        if let Some(config_dir) = config_path.parent() {
            let mut intermediate = dir.to_path_buf();
            while intermediate != config_dir && intermediate.starts_with(project_root) {
                cache.entry(intermediate.clone()).or_insert_with(|| result.clone());
                match intermediate.parent() {
                    Some(parent) => intermediate = parent.to_path_buf(),
                    None => break,
                }
            }
        }
    } else {
        // No config found - cache all directories up to project root
        let mut intermediate = dir.to_path_buf();
        while intermediate.starts_with(project_root) {
            cache.entry(intermediate.clone()).or_insert(None);
            if intermediate == project_root.to_path_buf() {
                break;
            }
            match intermediate.parent() {
                Some(parent) => intermediate = parent.to_path_buf(),
                None => break,
            }
        }
    }

    result
}

/// Apply CLI overrides that should be consistent across all config groups.
///
/// When a user passes `--flavor gfm` on the CLI, that should apply to all files
/// regardless of which subdirectory config they use.
fn apply_cli_config_overrides(config: &mut rumdl_config::Config, args: &crate::CheckArgs) {
    if let Some(flavor) = args.flavor {
        config.global.flavor = flavor.into();
    }

    if let Some(respect_gitignore) = args.respect_gitignore {
        config.global.respect_gitignore = respect_gitignore;
    }
}

#[cfg(test)]
mod tests {
    use super::config_scope_dir;
    use std::path::Path;

    #[test]
    fn config_scope_dir_uses_containing_dir_for_plain_configs() {
        for name in ["myproj/.rumdl.toml", "myproj/rumdl.toml", "myproj/pyproject.toml"] {
            assert_eq!(
                config_scope_dir(Path::new(name)),
                Some(Path::new("myproj")),
                "{name} should be scoped to its containing directory"
            );
        }
    }

    #[test]
    fn config_scope_dir_skips_dot_config_directory() {
        // `.config/rumdl.toml` governs the directory that holds `.config`, not
        // `.config` itself, so its per-file globs must resolve one level up.
        assert_eq!(
            config_scope_dir(Path::new("myproj/.config/rumdl.toml")),
            Some(Path::new("myproj"))
        );
    }
}