use core::error::Error;
use ignore::WalkBuilder;
use ignore::overrides::OverrideBuilder;
use rumdl_config::{WITHHELD, resolve_rule_names};
use rumdl_lib::config as rumdl_config;
use rumdl_lib::discovery::{
ExcludeMatchers, LintableFileMode, LintablePathSelector, MarkdownWalkOptions, apply_markdown_walk_options,
exclude_override_rule, expand_directory_pattern, has_markdown_extension, include_pattern_compiles,
normalize_pattern_for_base, path_relative_to,
};
use rumdl_lib::rule::Rule;
use std::collections::HashSet;
use std::path::Path;
pub fn get_enabled_rules_from_checkargs(args: &crate::CheckArgs, config: &rumdl_config::Config) -> Vec<Box<dyn Rule>> {
let all_rules: Vec<Box<dyn Rule>> = rumdl_lib::rules::all_rules(config);
let final_rules: Vec<Box<dyn Rule>>;
let cli_enable_set: Option<HashSet<String>> = args.enable.as_deref().map(resolve_rule_names);
let cli_disable_set: Option<HashSet<String>> = args.disable.as_deref().map(resolve_rule_names);
let cli_extend_enable_set: Option<HashSet<String>> = args.extend_enable.as_deref().map(resolve_rule_names);
let cli_extend_disable_set: Option<HashSet<String>> = args.extend_disable.as_deref().map(resolve_rule_names);
if let Some(enabled_cli) = &cli_enable_set {
let cli_enable_all = enabled_cli.iter().any(|v| v.eq_ignore_ascii_case("all"));
let cli_extend_enable_all = cli_extend_enable_set
.as_ref()
.is_some_and(|s| s.iter().any(|v| v.eq_ignore_ascii_case("all")));
let cli_extend_disable_all = cli_extend_disable_set
.as_ref()
.is_some_and(|s| s.iter().any(|v| v.eq_ignore_ascii_case("all")));
let mut current_rules = if cli_enable_all || cli_extend_enable_all {
all_rules
} else {
all_rules
.into_iter()
.filter(|rule| enabled_cli.contains(rule.name()))
.collect::<Vec<_>>()
};
if !cli_extend_enable_all && let Some(extend_enabled_cli) = &cli_extend_enable_set {
let already_enabled: HashSet<&str> = current_rules.iter().map(|r| r.name()).collect();
let additional: Vec<Box<dyn Rule>> = rumdl_lib::rules::all_rules(config)
.into_iter()
.filter(|rule| extend_enabled_cli.contains(rule.name()) && !already_enabled.contains(rule.name()))
.collect();
current_rules.extend(additional);
}
if cli_extend_disable_all {
current_rules.clear();
} else {
if let Some(extend_disabled_cli) = &cli_extend_disable_set {
current_rules.retain(|rule| !extend_disabled_cli.contains(rule.name()));
}
if let Some(disabled_cli) = &cli_disable_set {
current_rules.retain(|rule| !disabled_cli.contains(rule.name()));
}
}
final_rules = current_rules;
if args.verbose {
println!("Enabled rules:");
for rule in &final_rules {
println!(" - {} ({})", rule.name(), rule.description());
}
println!();
}
return final_rules;
}
let config_enable_set: HashSet<String> = config.global.enable.iter().cloned().collect();
let config_disable_set: HashSet<String> = config.global.disable.iter().cloned().collect();
let config_extend_enable_set: HashSet<String> = config.global.extend_enable.iter().cloned().collect();
let config_extend_disable_set: HashSet<String> = config.global.extend_disable.iter().cloned().collect();
let config_enable_all = config.global.enable.iter().any(|s| s.eq_ignore_ascii_case("all"));
let opt_in_set = rumdl_lib::rules::opt_in_rules();
let mut combined_extend_enable: HashSet<String> = config_extend_enable_set;
if let Some(ref cli_ee) = cli_extend_enable_set {
combined_extend_enable.extend(cli_ee.iter().cloned());
}
let mut combined_extend_disable: HashSet<String> = config_extend_disable_set;
if let Some(ref cli_ed) = cli_extend_disable_set {
combined_extend_disable.extend(cli_ed.iter().cloned());
}
let extend_enable_all = combined_extend_enable.iter().any(|s| s.eq_ignore_ascii_case("all"));
let extend_disable_all = combined_extend_disable.iter().any(|s| s.eq_ignore_ascii_case("all"));
let mut current_rules = if extend_enable_all {
all_rules
} else if config_enable_all {
all_rules
} else if !config_enable_set.is_empty() || config.global.enable_is_explicit {
all_rules
.into_iter()
.filter(|rule| config_enable_set.contains(rule.name()))
.collect::<Vec<_>>()
} else {
all_rules
.into_iter()
.filter(|rule| !opt_in_set.contains(rule.name()))
.collect::<Vec<_>>()
};
if !extend_enable_all && !combined_extend_enable.is_empty() {
let already_enabled: HashSet<&str> = current_rules.iter().map(|r| r.name()).collect();
let additional: Vec<Box<dyn Rule>> = rumdl_lib::rules::all_rules(config)
.into_iter()
.filter(|rule| combined_extend_enable.contains(rule.name()) && !already_enabled.contains(rule.name()))
.collect();
current_rules.extend(additional);
}
if extend_disable_all {
current_rules.clear();
} else {
if !config_disable_set.is_empty() {
current_rules.retain(|rule| !config_disable_set.contains(rule.name()));
}
if !combined_extend_disable.is_empty() {
current_rules.retain(|rule| !combined_extend_disable.contains(rule.name()));
}
if let Some(disabled_cli) = &cli_disable_set {
current_rules.retain(|rule| !disabled_cli.contains(rule.name()));
}
}
final_rules = current_rules;
if args.verbose {
println!("Enabled rules:");
for rule in &final_rules {
println!(" - {} ({})", rule.name(), rule.description());
}
println!();
}
final_rules
}
#[inline]
fn canonicalize_path_safe(path_str: &str) -> String {
Path::new(path_str)
.canonicalize()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|_| path_str.to_string())
}
pub fn to_display_path(file_path: &str, project_root: Option<&Path>) -> String {
let path = Path::new(file_path);
let canonical_file = path.canonicalize().ok();
let effective_path = canonical_file.as_deref().unwrap_or(path);
if let Some(root) = project_root
&& let Some(relative) = strip_base_prefix(effective_path, root)
{
return normalize_separators(relative);
}
if let Ok(cwd) = std::env::current_dir()
&& let Some(relative) = strip_base_prefix(effective_path, &cwd)
{
return normalize_separators(relative);
}
normalize_separators(file_path.to_string())
}
pub fn resolve_display_path(file_path: &str, show_full_path: bool, project_root: Option<&Path>) -> String {
if show_full_path {
normalize_separators(file_path.to_string())
} else {
to_display_path(file_path, project_root)
}
}
fn normalize_separators(path: String) -> String {
if cfg!(windows) { path.replace('\\', "/") } else { path }
}
pub(super) fn strip_base_prefix(file_path: &Path, base: &Path) -> Option<String> {
let canonical_base = base.canonicalize().ok()?;
if let Ok(relative) = file_path.strip_prefix(&canonical_base) {
return Some(relative.to_string_lossy().to_string());
}
if let Ok(relative) = file_path.strip_prefix(base) {
return Some(relative.to_string_lossy().to_string());
}
None
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EmptyDiscovery {
NoMarkdownFiles,
AllFiltered {
total: usize,
gitignore: usize,
exclude: usize,
not_included: usize,
unmatched_includes: Vec<String>,
},
}
impl EmptyDiscovery {
fn filtered(
total: usize,
gitignore: usize,
exclude: usize,
not_included: usize,
unmatched_includes: Vec<String>,
) -> Self {
if total == 0 {
return Self::NoMarkdownFiles;
}
Self::AllFiltered {
total,
gitignore,
exclude,
not_included,
unmatched_includes,
}
}
pub fn is_misconfiguration(&self) -> bool {
matches!(self, Self::AllFiltered { .. })
}
}
impl std::fmt::Display for EmptyDiscovery {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::NoMarkdownFiles => write!(f, "No markdown files found to check."),
Self::AllFiltered {
total,
gitignore,
exclude,
not_included,
unmatched_includes,
} => {
let (noun, verb) = if *total == 1 {
("file", "was")
} else {
("files", "were")
};
write!(
f,
"No markdown files left to check: {total} {noun} found {verb} filtered out."
)?;
if *gitignore > 0 {
write!(
f,
"\n {gitignore} by ignore files (.gitignore, .ignore, .markdownlintignore); pass --respect-gitignore=false to keep them"
)?;
}
if *exclude > 0 {
write!(f, "\n {exclude} by exclude patterns; pass --no-exclude to keep them")?;
}
if *not_included > 0 {
write!(f, "\n {not_included} by include patterns")?;
}
for line in unmatched_includes {
write!(f, "\n {line}")?;
}
Ok(())
}
}
}
}
pub struct Discovered {
pub files: Vec<String>,
pub empty_reason: Option<EmptyDiscovery>,
}
fn reachable_files(roots: &[&str], respect_gitignore: bool) -> impl Iterator<Item = std::path::PathBuf> + use<> {
reachable_entries(roots, respect_gitignore)
.filter(|entry| entry.file_type().is_some_and(|file_type| file_type.is_file()))
.map(ignore::DirEntry::into_path)
}
fn reachable_entries(roots: &[&str], respect_gitignore: bool) -> impl Iterator<Item = ignore::DirEntry> + use<> {
let walk = roots.split_first().map(|(first, rest)| {
let mut builder = WalkBuilder::new(first);
for root in rest {
builder.add(root);
}
apply_markdown_walk_options(
&mut builder,
roots,
&MarkdownWalkOptions {
respect_gitignore,
skip_vendor_dirs: false,
},
);
builder.build()
});
walk.into_iter().flatten().filter_map(Result::ok)
}
fn overrides_from(base: &Path, rules: impl IntoIterator<Item = String>) -> ignore::overrides::Override {
let mut builder = OverrideBuilder::new(base);
for rule in rules {
let _ = builder.add(&rule);
}
builder.build().unwrap_or_else(|_| ignore::overrides::Override::empty())
}
fn canonical_walk_path(path: &Path) -> std::path::PathBuf {
let raw = path.to_string_lossy();
std::path::PathBuf::from(canonicalize_path_safe(raw.strip_prefix("./").unwrap_or(&raw)))
}
#[derive(Clone, Copy)]
struct DiscoveryFilters<'a> {
lintable: &'a LintablePathSelector,
exclude_matchers: &'a ExcludeMatchers,
exclude_patterns: &'a [String],
include_patterns: &'a [String],
include_source_withheld: Option<&'a str>,
named_excluded: &'a [std::path::PathBuf],
pattern_base: &'a Path,
canonical_project_root: Option<&'a Path>,
respect_gitignore: bool,
}
fn diagnose_empty_discovery(roots: &[&str], filters: &DiscoveryFilters<'_>) -> EmptyDiscovery {
let DiscoveryFilters {
lintable,
exclude_matchers,
exclude_patterns,
include_patterns,
include_source_withheld,
named_excluded,
pattern_base,
canonical_project_root,
respect_gitignore,
} = *filters;
let excluded_by_pattern = overrides_from(pattern_base, exclude_patterns.iter().map(|p| exclude_override_rule(p)));
let included_by_pattern = overrides_from(pattern_base, include_patterns.iter().cloned());
let per_include: Vec<ignore::overrides::Override> = include_patterns
.iter()
.map(|pattern| overrides_from(pattern_base, [pattern.clone()]))
.collect();
let is_lintable = |path: &Path| {
if has_markdown_extension(path) {
return true;
}
if !included_by_pattern.matched(path, false).is_whitelist() {
return false;
}
lintable.keeps(&canonical_walk_path(path))
};
let excluded_after_walk = |path: &Path| {
if exclude_matchers.is_empty() {
return false;
}
let canonical = canonical_walk_path(path);
let relative = canonical_project_root.and_then(|root| path_relative_to(&canonical, root));
exclude_matchers.excludes_file(relative.as_deref(), &canonical)
};
let mut seen_paths: HashSet<std::path::PathBuf> = HashSet::new();
let mut is_repeat = move |path: &Path| roots.len() > 1 && !seen_paths.insert(canonical_walk_path(path));
let named_excluded_paths: HashSet<&Path> = named_excluded.iter().map(std::path::PathBuf::as_path).collect();
let already_counted = |path: &Path| {
if named_excluded_paths.is_empty() {
return false;
}
named_excluded_paths.contains(canonical_walk_path(path).as_path())
};
let mut matched_includes = vec![false; per_include.len()];
let note_include_matches = |path: &Path, matched: &mut Vec<bool>| {
for (pattern, seen) in per_include.iter().zip(matched.iter_mut()) {
*seen = *seen || pattern.matched(path, false).is_whitelist();
}
};
let include_verdict = |path: &Path| included_by_pattern.matched(path, false);
let (mut total, mut gitignore, mut exclude, mut not_included) = (0, 0, 0, 0);
let mut reached_dirs: HashSet<std::path::PathBuf> = HashSet::new();
for entry in reachable_entries(roots, respect_gitignore) {
if entry.file_type().is_some_and(|file_type| file_type.is_dir()) {
reached_dirs.insert(canonical_walk_path(entry.path()));
continue;
}
if !entry.file_type().is_some_and(|file_type| file_type.is_file()) {
continue;
}
let path = entry.into_path();
if !is_lintable(&path) || is_repeat(&path) {
continue;
}
note_include_matches(&path, &mut matched_includes);
if already_counted(&path) {
continue;
}
total += 1;
if excluded_by_pattern.matched(&path, false).is_ignore() || excluded_after_walk(&path) {
exclude += 1;
} else if include_verdict(&path).is_ignore() {
not_included += 1;
}
}
if total == 0 && named_excluded.is_empty() && respect_gitignore {
let reached = |path: &Path| {
path.parent()
.is_some_and(|dir| reached_dirs.contains(&canonical_walk_path(dir)))
};
for path in reachable_files(roots, false) {
if !is_lintable(&path) || is_repeat(&path) {
continue;
}
note_include_matches(&path, &mut matched_includes);
total += 1;
let verdict = include_verdict(&path);
if verdict.is_ignore() {
not_included += 1;
} else if verdict.is_whitelist() && reached(&path) {
exclude += 1;
} else {
gitignore += 1;
}
}
}
let unmatched: Vec<&str> = include_patterns
.iter()
.zip(&matched_includes)
.filter(|(pattern, matched)| !**matched && include_pattern_compiles(pattern))
.map(|(pattern, _)| pattern.as_str())
.collect();
let unmatched_includes = unmatched_include_lines(&unmatched, include_source_withheld);
let named = named_excluded.len();
EmptyDiscovery::filtered(
total + named,
gitignore,
exclude + named,
not_included,
unmatched_includes,
)
}
fn unmatched_include_lines(unmatched: &[&str], withheld_source: Option<&str>) -> Vec<String> {
match withheld_source {
None => unmatched
.iter()
.map(|pattern| format!("include pattern '{pattern}' matches no file"))
.collect(),
Some(_) if unmatched.is_empty() => Vec::new(),
Some(source) => {
let (noun, verb) = if unmatched.len() == 1 {
("pattern", "matches")
} else {
("patterns", "match")
};
vec![format!("{} include {noun} in {source} {verb} no file", unmatched.len())]
}
}
}
pub fn find_markdown_files(
paths: &[String],
args: &crate::CheckArgs,
config: &rumdl_config::Config,
project_root: Option<&std::path::Path>,
) -> Result<Discovered, Box<dyn Error>> {
let mut file_paths = Vec::new();
let is_discovery_mode = paths.is_empty() || paths == ["."];
let has_config_include = is_discovery_mode && !config.global.include.is_empty();
let include_base = project_root
.map(Path::to_path_buf)
.or_else(|| std::env::current_dir().ok());
let normalize_include = |pattern: &str| normalize_pattern_for_base(pattern, include_base.as_deref());
let config_include: Vec<String> = config
.global
.include
.iter()
.map(|pattern| normalize_include(pattern))
.collect();
let final_include_patterns: Vec<String> = if let Some(cli_include) = args.include.as_deref() {
cli_include
.split(',')
.map(|p| p.trim())
.filter(|p| !p.is_empty())
.map(normalize_include)
.collect()
} else if is_discovery_mode && !config.global.include.is_empty() {
config_include.clone()
} else if is_discovery_mode {
Vec::new()
} else {
Vec::new()
};
let include_source_withheld: Option<&str> = args
.include
.is_none()
.then_some(config.global.include_withheld.as_deref())
.flatten();
let raw_exclude_patterns: Vec<String> = if args.no_exclude {
Vec::new() } else if let Some(cli_exclude) = args.exclude.as_deref() {
cli_exclude
.split(',')
.map(|p| p.trim().to_string())
.filter(|p| !p.is_empty())
.collect()
} else {
config.global.exclude.clone()
};
let final_exclude_patterns: Vec<String> = raw_exclude_patterns
.iter()
.flat_map(|p| expand_directory_pattern(p))
.collect();
if args.verbose {
eprintln!("Exclude patterns: {final_exclude_patterns:?}");
}
let exclude_matchers = ExcludeMatchers::new(&raw_exclude_patterns);
for (pattern, error) in &exclude_matchers.invalid {
eprintln!("Warning: Invalid exclude pattern '{pattern}': {error}");
}
let canonical_project_root = project_root.and_then(|root| root.canonicalize().ok());
let selector_base = canonical_project_root.clone().or_else(|| std::env::current_dir().ok());
let selector_includes = if has_config_include {
config_include.as_slice()
} else {
&[]
};
let selector_mode = if args.include.is_some() {
LintableFileMode::Any
} else if has_config_include {
LintableFileMode::MarkdownAndRust
} else {
LintableFileMode::Markdown
};
let lintable = LintablePathSelector::new(selector_base.as_deref(), selector_includes, selector_mode);
let mut explicit_files: Vec<String> = Vec::new();
let mut explicit_dirs: Vec<&str> = Vec::new();
let mut excluded_named_files: Vec<std::path::PathBuf> = Vec::new();
if !is_discovery_mode {
for path_str in paths {
let path = Path::new(path_str);
if !path.exists() {
return Err(format!("File not found: {path_str}").into());
}
if !path.is_file() {
explicit_dirs.push(path_str.as_str());
continue;
}
let cleaned_path = if path.is_absolute() {
if let Ok(cwd) = std::env::current_dir() {
if let (Ok(canonical_cwd), Ok(canonical_path)) = (cwd.canonicalize(), path.canonicalize()) {
if let Ok(relative) = canonical_path.strip_prefix(&canonical_cwd) {
relative.to_string_lossy().to_string()
} else {
path_str.clone()
}
} else {
path_str.clone()
}
} else {
path_str.clone()
}
} else if let Some(stripped) = path_str.strip_prefix("./") {
stripped.to_string()
} else {
path_str.clone()
};
if !exclude_matchers.is_empty() {
let path_for_matching = canonical_project_root
.as_deref()
.and_then(|root| path_relative_to(path, root))
.unwrap_or_else(|| cleaned_path.clone());
if let Some(pattern) = exclude_matchers.matched_pattern_for_file(Some(&path_for_matching), path) {
excluded_named_files.push(std::path::PathBuf::from(canonicalize_path_safe(&cleaned_path)));
if args.verbose && !args.silent {
let display_path = normalize_separators(cleaned_path.clone());
eprintln!(
"{display_path} ignored because of exclude pattern '{pattern}'. Use --no-exclude to override"
);
}
} else {
explicit_files.push(canonicalize_path_safe(&cleaned_path));
}
} else {
explicit_files.push(canonicalize_path_safe(&cleaned_path));
}
}
excluded_named_files.sort();
excluded_named_files.dedup();
if explicit_dirs.is_empty() {
explicit_files.sort();
explicit_files.dedup();
let excluded = excluded_named_files.len();
let empty_reason = explicit_files
.is_empty()
.then(|| EmptyDiscovery::filtered(excluded, 0, excluded, 0, Vec::new()));
return Ok(Discovered {
files: explicit_files,
empty_reason,
});
}
}
let walk_roots: Vec<&str> = if is_discovery_mode {
vec![paths.first().map(String::as_str).unwrap_or(".")]
} else {
explicit_dirs.clone()
};
let mut walk_builder = {
let (first, rest) = walk_roots.split_first().expect("a walk always has at least one root");
let mut builder = WalkBuilder::new(first);
for dir in rest {
builder.add(dir);
}
builder
};
lintable.configure_types(&mut walk_builder)?;
if !final_include_patterns.is_empty() || !final_exclude_patterns.is_empty() {
let pattern_base = project_root.unwrap_or(Path::new("."));
let mut override_builder = OverrideBuilder::new(pattern_base);
for pattern in &final_include_patterns {
match (override_builder.add(pattern), include_source_withheld) {
(Ok(_), _) => {}
(Err(_), Some(source)) => {
eprintln!("Warning: Invalid include pattern in {source}: {WITHHELD}");
}
(Err(e), None) => eprintln!("Warning: Invalid include pattern '{pattern}': {e}"),
}
}
for pattern in &final_exclude_patterns {
let exclude_rule = exclude_override_rule(pattern);
if let Err(e) = override_builder.add(&exclude_rule) {
eprintln!("Warning: Invalid exclude pattern '{pattern}': {e}");
}
}
match override_builder.build() {
Ok(overrides) => {
walk_builder.overrides(overrides);
}
Err(e) => {
eprintln!("Error building path overrides: {e}");
}
};
}
apply_markdown_walk_options(
&mut walk_builder,
&walk_roots,
&MarkdownWalkOptions {
respect_gitignore: config.global.respect_gitignore,
skip_vendor_dirs: false,
},
);
for result in walk_builder.build() {
match result {
Ok(entry) => {
let path = entry.path();
if entry.file_type().is_some_and(|file_type| file_type.is_file()) {
let file_path = path.to_string_lossy().to_string();
let cleaned_path = if let Some(stripped) = file_path.strip_prefix("./") {
stripped.to_string()
} else {
file_path
};
file_paths.push(canonicalize_path_safe(&cleaned_path));
}
}
Err(err) => {
if is_discovery_mode {
eprintln!("Error walking directory: {err}");
}
}
}
}
file_paths.sort();
file_paths.dedup();
if !exclude_matchers.is_empty() {
file_paths.retain(|file_path| {
let path = Path::new(file_path);
let path_for_matching = canonical_project_root
.as_deref()
.and_then(|root| path_relative_to(path, root));
!exclude_matchers.excludes_file(path_for_matching.as_deref(), path)
});
}
file_paths.retain(|path_str| lintable.keeps(Path::new(path_str)));
file_paths.extend(explicit_files);
file_paths.sort();
file_paths.dedup();
let empty_reason = file_paths.is_empty().then(|| {
diagnose_empty_discovery(
&walk_roots,
&DiscoveryFilters {
lintable: &lintable,
exclude_matchers: &exclude_matchers,
exclude_patterns: &final_exclude_patterns,
include_patterns: &final_include_patterns,
include_source_withheld,
named_excluded: &excluded_named_files,
pattern_base: project_root.unwrap_or(Path::new(".")),
canonical_project_root: canonical_project_root.as_deref(),
respect_gitignore: config.global.respect_gitignore,
},
)
});
Ok(Discovered {
files: file_paths,
empty_reason,
})
}