use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
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, RuleSets};
pub struct ConfigGroup {
pub config: rumdl_config::Config,
pub rule_sets: RuleSets,
pub cache_hashes: Option<Arc<CacheHashes>>,
pub files: Vec<String>,
}
pub fn report_only_mode_without_tools(groups: &[ConfigGroup], args: &crate::CheckArgs) -> bool {
if args.code_block_tools_mode() != crate::CodeBlockToolsMode::Only
|| groups.iter().any(|group| runs_a_tool(&group.config.code_block_tools))
{
return false;
}
if !args.silent {
eprintln!(
"\x1b[33m[config warning]\x1b[0m --only-code-block-tools: no code-block tools are \
configured, so nothing was checked"
);
}
true
}
pub fn report_missing_tool_binaries(groups: &[ConfigGroup], args: &crate::CheckArgs) -> bool {
use rumdl_lib::code_block_tools::{OnMissing, RUMDL_BUILTIN_TOOL, ToolExecutor, ToolRegistry, ToolSlot};
if args.code_block_tools_mode() == crate::CodeBlockToolsMode::Disabled {
return false;
}
let slot = if args.fix_mode == crate::FixMode::Check {
ToolSlot::Lint
} else {
ToolSlot::Format
};
let mut missing: BTreeSet<String> = BTreeSet::new();
for group in groups {
let config = &group.config.code_block_tools;
if !config.enabled || config.on_missing_tool_binary != OnMissing::Warn {
continue;
}
let executor = ToolExecutor::new(config.timeout);
let registry = ToolRegistry::new(config.tools.clone());
for language in config.languages.values() {
if !language.enabled {
continue;
}
let tool_ids = match slot {
ToolSlot::Lint => &language.lint,
ToolSlot::Format => &language.format,
};
for tool_id in tool_ids {
if tool_id == RUMDL_BUILTIN_TOOL {
continue;
}
let Some(tool_def) = registry.resolve(tool_id, slot) else {
continue;
};
let Some(binary) = tool_def.command.first() else {
continue;
};
if !executor.is_tool_available(binary) {
missing.insert(binary.clone());
}
}
}
}
if missing.is_empty() {
return false;
}
if !args.silent {
let names: Vec<&str> = missing.iter().map(String::as_str).collect();
eprintln!(
"\x1b[33m[config warning]\x1b[0m code-block tools not installed: {}. \
Those code blocks were not checked. Install them, or set \
`code-block-tools.on-missing-tool-binary` to \"fail\" to stop the run \
or \"ignore\" to accept the gap",
names.join(", ")
);
}
true
}
fn runs_a_tool(config: &rumdl_lib::code_block_tools::CodeBlockToolsConfig) -> bool {
config
.languages
.values()
.any(|language| language.enabled && !(language.lint.is_empty() && language.format.is_empty()))
}
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();
crate::apply_runtime_cli_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.rule_sets.configuration_relevant_rule_names(&built.config),
&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();
crate::apply_runtime_cli_overrides(&mut config, args);
config
}
fn rule_runs_for(rule: &str, relevant_rules: &HashSet<String>, config: &rumdl_config::Config, path: &Path) -> bool {
relevant_rules.contains(rule) && !config.get_ignored_rules_for_file(path).contains(rule)
}
fn report_editorconfig_warnings(
warnings: &[(String, EditorConfigWarning)],
relevant_rules: &HashSet<String>,
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, relevant_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 relevant_rules: HashSet<String> = rules.iter().map(|rule| rule.name().to_string()).collect();
let config_warning = report_editorconfig_warnings(&warnings, &relevant_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 rule_sets = crate::file_processor::get_rule_sets_from_checkargs(args, &config);
let cache_hashes = cache.as_ref().map(|_| Arc::new(CacheHashes::new(&config, &rule_sets)));
ConfigGroup {
config,
rule_sets,
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
}
#[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"))
);
}
}