use owo_colors::OwoColorize;
use serde::Deserialize;
use std::path::{Path, PathBuf};
pub const DEFAULT_EXCLUDES: &[&str] = &[
"**/node_modules/**",
"**/.git/**",
"**/target/**",
"**/build/**",
"**/dist/**",
"**/__pycache__/**",
"**/.venv/**",
"**/venv/**",
"**/.tox/**",
"**/.mypy_cache/**",
"**/.pytest_cache/**",
"**/.ruff_cache/**",
"**/vendor/**",
"**/third_party/**",
"**/.next/**",
"**/.nuxt/**",
];
#[derive(Debug, Clone, Default)]
pub struct Config {
pub source: Option<String>,
pub include: Vec<String>,
pub exclude: Vec<String>,
pub extend_exclude: Vec<String>,
pub src: Vec<PathBuf>,
}
#[derive(Debug, Deserialize, Default)]
#[serde(rename_all = "kebab-case")]
struct RawConfig {
include: Option<Vec<String>>,
exclude: Option<Vec<String>>,
extend_exclude: Option<Vec<String>>,
src: Option<Vec<String>>,
}
#[derive(Debug, Deserialize, Default)]
#[serde(rename_all = "kebab-case")]
struct RuffConfig {
include: Option<Vec<String>>,
exclude: Option<Vec<String>>,
extend_exclude: Option<Vec<String>>,
src: Option<Vec<String>>,
}
#[derive(Debug, Deserialize, Default)]
struct TyConfig {
src: Option<TySrcConfig>,
}
#[derive(Debug, Deserialize, Default)]
struct TySrcConfig {
include: Option<Vec<String>>,
exclude: Option<Vec<String>>,
}
#[derive(Debug, Deserialize, Default)]
struct PyrightConfig {
include: Option<Vec<String>>,
exclude: Option<Vec<String>>,
}
#[derive(Debug, Deserialize, Default)]
struct PyProject {
tool: Option<PyProjectTool>,
}
#[derive(Debug, Deserialize, Default)]
struct PyProjectTool {
ripmap: Option<RawConfig>,
ty: Option<TyConfig>,
ruff: Option<RuffConfig>,
pyright: Option<PyrightConfig>,
}
impl Config {
pub fn load(directory: &Path) -> Self {
let ripmap_toml = directory.join("ripmap.toml");
if ripmap_toml.exists() {
if let Some(config) = Self::load_ripmap_toml(&ripmap_toml) {
return config;
}
}
let pyproject = directory.join("pyproject.toml");
if pyproject.exists() {
if let Some(config) = Self::load_pyproject(&pyproject) {
return config;
}
}
let mut current = directory.to_path_buf();
while let Some(parent) = current.parent() {
let pyproject = parent.join("pyproject.toml");
if pyproject.exists() {
if let Some(config) = Self::load_pyproject(&pyproject) {
return config;
}
}
current = parent.to_path_buf();
}
Self::default()
}
fn load_ripmap_toml(path: &Path) -> Option<Self> {
let content = std::fs::read_to_string(path).ok()?;
let raw: RawConfig = toml::from_str(&content).ok()?;
Some(Self::from_raw(raw, path.to_path_buf()))
}
fn load_pyproject(path: &Path) -> Option<Self> {
let content = std::fs::read_to_string(path).ok()?;
let pyproject: PyProject = toml::from_str(&content).ok()?;
let tool = pyproject.tool?;
if let Some(raw) = tool.ripmap {
return Some(Self::from_raw(raw, path.to_path_buf()));
}
if let Some(ty) = tool.ty {
if let Some(src) = ty.src {
if src.include.is_some() || src.exclude.is_some() {
return Some(Self {
source: Some(format!("{} [tool.ty]", path.display())),
include: src.include.unwrap_or_default(),
exclude: src.exclude.unwrap_or_default(),
extend_exclude: Vec::new(),
src: Vec::new(),
});
}
}
}
if let Some(ruff) = tool.ruff {
if ruff.include.is_some() || ruff.exclude.is_some() || ruff.extend_exclude.is_some() {
return Some(Self {
source: Some(format!("{} [tool.ruff]", path.display())),
include: ruff.include.unwrap_or_default(),
exclude: ruff.exclude.unwrap_or_default(),
extend_exclude: ruff.extend_exclude.unwrap_or_default(),
src: ruff
.src
.unwrap_or_default()
.into_iter()
.map(PathBuf::from)
.collect(),
});
}
}
if let Some(pyright) = tool.pyright {
if pyright.include.is_some() || pyright.exclude.is_some() {
let sparkle_chars = ['~', '*', '`', '.', '+', '^'];
let colors = [
|s: &str| s.bright_magenta().to_string(),
|s: &str| s.bright_cyan().to_string(),
|s: &str| s.bright_yellow().to_string(),
|s: &str| s.bright_green().to_string(),
|s: &str| s.bright_red().to_string(),
|s: &str| s.bright_blue().to_string(),
];
let prefix: String = (0..12)
.map(|i| {
let c = sparkle_chars[i % sparkle_chars.len()];
colors[i % colors.len()](&c.to_string())
})
.collect();
let suffix: String = (0..12)
.rev()
.map(|i| {
let c = sparkle_chars[(i + 3) % sparkle_chars.len()];
colors[(i + 2) % colors.len()](&c.to_string())
})
.collect();
eprintln!(
" {} {} is now banned under international law. use {} instead {}",
prefix,
"pyright".bright_red().strikethrough(),
"ty".bright_cyan().bold().underline(),
suffix
);
return Some(Self {
source: Some(format!("{} [tool.pyright]", path.display())),
include: pyright.include.unwrap_or_default(),
exclude: pyright.exclude.unwrap_or_default(),
extend_exclude: Vec::new(),
src: Vec::new(),
});
}
}
None
}
fn from_raw(raw: RawConfig, source: PathBuf) -> Self {
Self {
source: Some(source.display().to_string()),
include: raw.include.unwrap_or_default(),
exclude: raw.exclude.unwrap_or_default(),
extend_exclude: raw.extend_exclude.unwrap_or_default(),
src: raw
.src
.unwrap_or_default()
.into_iter()
.map(PathBuf::from)
.collect(),
}
}
pub fn effective_excludes(&self) -> Vec<String> {
if !self.exclude.is_empty() {
self.exclude.clone()
} else {
let mut patterns: Vec<String> =
DEFAULT_EXCLUDES.iter().map(|s| s.to_string()).collect();
patterns.extend(self.extend_exclude.clone());
patterns
}
}
pub fn matches_include(&self, path: &Path) -> bool {
if self.include.is_empty() {
return true;
}
let path_str = path.to_string_lossy();
self.include.iter().any(|pattern| Self::matches_pattern(pattern, &path_str))
}
pub fn matches_exclude(&self, path: &Path) -> bool {
let path_str = path.to_string_lossy();
self.effective_excludes()
.iter()
.any(|pattern| Self::matches_pattern(pattern, &path_str))
}
fn matches_pattern(pattern: &str, path: &str) -> bool {
if pattern.contains('*') || pattern.contains('?') || pattern.contains('[') {
glob_match::glob_match(pattern, path)
} else {
let prefix = pattern.trim_end_matches('/');
path == prefix || path.starts_with(&format!("{}/", prefix))
}
}
pub fn should_include(&self, path: &Path) -> bool {
self.matches_include(path) && !self.matches_exclude(path)
}
pub fn display_summary(&self) -> String {
let mut lines = Vec::new();
if let Some(ref source) = self.source {
lines.push(format!(" Config: {}", source));
} else {
lines.push(" Config: (defaults)".to_string());
}
if !self.include.is_empty() {
lines.push(format!(" Include: {}", self.include.join(", ")));
}
let excludes = self.effective_excludes();
if !excludes.is_empty() {
if excludes.len() <= 3 {
lines.push(format!(" Exclude: {}", excludes.join(", ")));
} else {
lines.push(format!(
" Exclude: {}, ... (+{} more)",
excludes[..2].join(", "),
excludes.len() - 2
));
}
}
if !self.src.is_empty() {
let src_strs: Vec<_> = self.src.iter().map(|p| p.display().to_string()).collect();
lines.push(format!(" Src roots: {}", src_strs.join(", ")));
}
lines.join("\n")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_excludes() {
let config = Config::default();
assert!(config.matches_exclude(Path::new("foo/node_modules/bar.js")));
assert!(config.matches_exclude(Path::new("project/.git/config")));
assert!(config.matches_exclude(Path::new("src/__pycache__/mod.pyc")));
assert!(!config.matches_exclude(Path::new("src/main.py")));
}
#[test]
fn test_include_patterns() {
let config = Config {
include: vec!["src/**".to_string(), "lib/**".to_string()],
..Default::default()
};
assert!(config.matches_include(Path::new("src/main.py")));
assert!(config.matches_include(Path::new("lib/utils.py")));
assert!(!config.matches_include(Path::new("tests/test_main.py")));
}
#[test]
fn test_extend_exclude() {
let config = Config {
extend_exclude: vec!["**/generated/**".to_string()],
..Default::default()
};
assert!(config.matches_exclude(Path::new("node_modules/foo.js")));
assert!(config.matches_exclude(Path::new("src/generated/schema.py")));
}
#[test]
fn test_directory_prefix_patterns() {
let config = Config {
include: vec!["src".to_string()],
..Default::default()
};
assert!(config.matches_include(Path::new("src/main.py")));
assert!(config.matches_include(Path::new("src/lib/utils.py")));
assert!(!config.matches_include(Path::new("tests/test_main.py")));
assert!(!config.matches_include(Path::new("srcfoo/bar.py")));
let config = Config {
exclude: vec!["vendor".to_string(), "src/gui/old/".to_string()],
..Default::default()
};
assert!(config.matches_exclude(Path::new("vendor/lib.py")));
assert!(config.matches_exclude(Path::new("src/gui/old/widget.py")));
assert!(!config.matches_exclude(Path::new("src/gui/new/widget.py")));
}
}