use core::error::Error;
use globset::{Glob, GlobMatcher};
use ignore::WalkBuilder;
use ignore::overrides::OverrideBuilder;
use rumdl_config::resolve_rule_names;
use rumdl_lib::config as rumdl_config;
use rumdl_lib::rule::Rule;
use std::collections::HashSet;
use std::path::Path;
fn expand_directory_pattern(pattern: &str) -> Vec<String> {
if pattern.contains('*') || pattern.contains('?') || pattern.contains('[') {
return vec![pattern.to_string()];
}
let base = pattern.trim_end_matches('/');
vec![
base.to_string(), format!("{base}/**"), ]
}
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())
}
fn compile_exclude_matchers(patterns: &[String]) -> Vec<(String, GlobMatcher)> {
patterns
.iter()
.filter_map(|pattern| match Glob::new(pattern) {
Ok(glob) => Some((pattern.clone(), glob.compile_matcher())),
Err(e) => {
eprintln!("Warning: Invalid exclude pattern '{pattern}': {e}");
None
}
})
.collect()
}
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
}
pub fn find_markdown_files(
paths: &[String],
args: &crate::CheckArgs,
config: &rumdl_config::Config,
project_root: Option<&std::path::Path>,
) -> Result<Vec<String>, Box<dyn Error>> {
let mut file_paths = Vec::new();
let first_path = paths.first().cloned().unwrap_or_else(|| ".".to_string());
let mut walk_builder = WalkBuilder::new(first_path);
for path in paths.iter().skip(1) {
walk_builder.add(path);
}
let is_discovery_mode = paths.is_empty() || paths == ["."];
let has_config_include = is_discovery_mode && !config.global.include.is_empty();
if args.include.is_none() {
let mut types_builder = ignore::types::TypesBuilder::new();
types_builder.add_defaults();
types_builder.add("markdown", "*.md")?;
types_builder.add("markdown", "*.markdown")?;
types_builder.add("markdown", "*.mdx")?;
types_builder.add("markdown", "*.mkd")?;
types_builder.add("markdown", "*.mkdn")?;
types_builder.add("markdown", "*.mdown")?;
types_builder.add("markdown", "*.mdwn")?;
types_builder.add("markdown", "*.qmd")?;
types_builder.add("markdown", "*.rmd")?;
types_builder.add("markdown", "*.Rmd")?;
types_builder.select("markdown");
if has_config_include {
types_builder.add("rustdoc", "*.rs")?;
types_builder.select("rustdoc");
}
let types = types_builder.build()?;
walk_builder.types(types);
}
let final_include_patterns: Vec<String> = if let Some(cli_include) = args.include.as_deref() {
cli_include
.split(',')
.map(|p| p.trim().to_string())
.filter(|p| !p.is_empty())
.collect()
} else if is_discovery_mode && !config.global.include.is_empty() {
config.global.include.clone()
} else if is_discovery_mode {
Vec::new()
} else {
Vec::new()
};
let final_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())
.flat_map(|p| expand_directory_pattern(&p))
.collect()
} else {
config
.global
.exclude
.iter()
.flat_map(|p| expand_directory_pattern(p))
.collect()
};
if args.verbose {
eprintln!("Exclude patterns: {final_exclude_patterns:?}");
}
let exclude_matchers = compile_exclude_matchers(&final_exclude_patterns);
let canonical_project_root = project_root.and_then(|root| root.canonicalize().ok());
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 {
if let Err(e) = override_builder.add(pattern) {
eprintln!("Warning: Invalid include pattern '{pattern}': {e}");
}
}
for pattern in &final_exclude_patterns {
let exclude_rule = if pattern.starts_with('!') {
pattern.clone() } else {
format!("!{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}");
}
};
}
let use_gitignore = config.global.respect_gitignore;
walk_builder.ignore(use_gitignore); walk_builder.git_ignore(use_gitignore); walk_builder.git_global(use_gitignore); walk_builder.git_exclude(use_gitignore); walk_builder.parents(use_gitignore); walk_builder.hidden(false); walk_builder.require_git(false);
walk_builder.add_custom_ignore_filename(".markdownlintignore");
if !is_discovery_mode {
let mut processed_explicit_files = false;
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() {
processed_explicit_files = true;
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 !final_exclude_patterns.is_empty() {
let path_for_matching = if let Some(canonical_root) = canonical_project_root.as_deref() {
if let Ok(canonical_path) = path.canonicalize() {
if let Ok(relative) = canonical_path.strip_prefix(canonical_root) {
relative.to_string_lossy().to_string()
} else {
cleaned_path.clone()
}
} else {
cleaned_path.clone()
}
} else {
cleaned_path.clone()
};
let mut matching_pattern: Option<&str> = None;
for (pattern, matcher) in &exclude_matchers {
if matcher.is_match(&path_for_matching) {
matching_pattern = Some(pattern.as_str());
break;
}
}
if let Some(pattern) = matching_pattern {
let display_path = normalize_separators(cleaned_path.clone());
eprintln!(
"warning: {display_path} ignored because of exclude pattern '{pattern}'. Use --no-exclude to override"
);
} else {
file_paths.push(canonicalize_path_safe(&cleaned_path));
}
} else {
file_paths.push(canonicalize_path_safe(&cleaned_path));
}
}
}
if processed_explicit_files {
file_paths.sort();
file_paths.dedup();
return Ok(file_paths);
}
}
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()
&& let Some(canonical_root) = canonical_project_root.as_deref()
{
file_paths.retain(|file_path| {
let path = Path::new(file_path);
let path_for_matching = if let Ok(canonical_path) = path.canonicalize() {
if let Ok(relative) = canonical_path.strip_prefix(canonical_root) {
relative.to_string_lossy().to_string()
} else {
file_path.clone()
}
} else {
file_path.clone()
};
for (_, matcher) in &exclude_matchers {
if matcher.is_match(&path_for_matching) {
return false; }
}
true });
}
if args.include.is_none() {
file_paths.retain(|path_str| {
let path = Path::new(path_str);
path.extension().is_some_and(|ext| {
let is_markdown = matches!(
ext.to_str(),
Some("md" | "markdown" | "mdx" | "mkd" | "mkdn" | "mdown" | "mdwn" | "qmd" | "rmd" | "Rmd")
);
let is_rust = has_config_include && ext.to_str() == Some("rs");
is_markdown || is_rust
})
});
}
Ok(file_paths) }