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;
pub struct ConfigGroup {
pub config: rumdl_config::Config,
pub rules: Vec<Box<dyn Rule>>,
pub cache_hashes: Option<Arc<CacheHashes>>,
pub files: Vec<String>,
}
pub struct RootConfig<'a> {
pub config: &'a rumdl_config::Config,
pub sourced: &'a rumdl_config::SourcedConfig<rumdl_config::ConfigValidated>,
}
pub struct ResolutionRoots<'a> {
pub grouping_root: Option<&'a Path>,
pub project_root: Option<&'a Path>,
}
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)
}
}
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);
parent == canon(project_root) || parent == canon(&project_root.join(".config"))
}
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();
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();
let mut dir_config_cache: HashMap<PathBuf, Option<PathBuf>> = HashMap::new();
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(),
};
let config_path = discover_with_cache(&parent_dir, grouping_root, &mut dir_config_cache);
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 => {
grouping.push_groups(root.sourced, root.config.clone(), files, args, cache);
}
Some(path) => {
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) => {
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) => {
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()
}
pub struct ResolvedGroups {
pub groups: Vec<ConfigGroup>,
pub config_warning: bool,
}
#[derive(Default)]
struct Grouping {
groups: Vec<ConfigGroup>,
reported: BTreeSet<String>,
config_warning: bool,
}
impl Grouping {
fn finish(self) -> ResolvedGroups {
ResolvedGroups {
groups: self.groups,
config_warning: self.config_warning,
}
}
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;
}
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);
}
}
}
#[derive(Default)]
struct SettingsGroup {
files: Vec<String>,
warnings: Vec<(String, EditorConfigWarning)>,
}
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
}
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)
}
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
}
pub struct StdinConfig {
pub config: rumdl_config::Config,
pub rules: Vec<Box<dyn Rule>>,
pub config_warning: bool,
}
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,
}
}
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,
}
}
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();
}
let result = rumdl_config::SourcedConfig::discover_config_for_dir(dir, project_root);
cache.insert(dir.to_path_buf(), result.clone());
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 {
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
}
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() {
assert_eq!(
config_scope_dir(Path::new("myproj/.config/rumdl.toml")),
Some(Path::new("myproj"))
);
}
}