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, strip_verbatim_prefix,
};
use rumdl_lib::rule::Rule;
use std::collections::HashSet;
use std::path::Path;
pub struct RuleSelectionFlags<'a> {
pub enable: Option<&'a str>,
pub disable: Option<&'a str>,
pub extend_enable: Option<&'a str>,
pub extend_disable: Option<&'a str>,
}
impl<'a> From<&'a crate::cli_types::SharedCliArgs> for RuleSelectionFlags<'a> {
fn from(args: &'a crate::cli_types::SharedCliArgs) -> Self {
Self {
enable: args.enable.as_deref(),
disable: args.disable.as_deref(),
extend_enable: args.extend_enable.as_deref(),
extend_disable: args.extend_disable.as_deref(),
}
}
}
fn resolve_flag(list: Option<&str>) -> Vec<String> {
let mut names: Vec<String> = list.map(resolve_rule_names).unwrap_or_default().into_iter().collect();
names.sort_unstable();
names
}
fn extended(base: &[String], flag: Option<&str>) -> Vec<String> {
let mut names = base.to_vec();
for name in resolve_flag(flag) {
if !names.contains(&name) {
names.push(name);
}
}
names
}
pub fn rule_selection(
flags: &RuleSelectionFlags<'_>,
config: &rumdl_config::GlobalConfig,
) -> rumdl_config::GlobalConfig {
match flags.enable {
Some(enable) => rumdl_config::GlobalConfig {
enable: resolve_flag(Some(enable)),
enable_is_explicit: true,
disable: resolve_flag(flags.disable),
extend_enable: resolve_flag(flags.extend_enable),
extend_disable: resolve_flag(flags.extend_disable),
..config.clone()
},
None => rumdl_config::GlobalConfig {
disable: extended(&config.disable, flags.disable),
extend_enable: extended(&config.extend_enable, flags.extend_enable),
extend_disable: extended(&config.extend_disable, flags.extend_disable),
..config.clone()
},
}
}
pub fn get_enabled_rules_from_checkargs(args: &crate::CheckArgs, config: &rumdl_config::Config) -> Vec<Box<dyn Rule>> {
let selection = rule_selection(&RuleSelectionFlags::from(&args.shared), &config.global);
let all_rules = rumdl_lib::rules::all_rules(config);
let final_rules = rumdl_lib::rules::filter_rules(&all_rules, &selection);
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_for_display(relative);
}
if let Ok(cwd) = std::env::current_dir()
&& let Some(relative) = strip_base_prefix(effective_path, &cwd)
{
return normalize_for_display(relative);
}
normalize_for_display(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_for_display(file_path.to_string())
} else {
to_display_path(file_path, project_root)
}
}
fn normalize_for_display(path: String) -> String {
if cfg!(windows) {
windows_display_path(&path)
} else {
path
}
}
pub(super) fn windows_display_path(path: &str) -> String {
strip_verbatim_prefix(path).replace('\\', "/")
}
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_for_display(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,
})
}