use std::path::{Path, PathBuf}; use std::str::FromStr; use anyhow::{anyhow, Result}; use common_path::common_path_all; use path_absolutize::Absolutize; use serde::de; use serde::{Deserialize, Deserializer}; use crate::checks::CheckCode; use crate::fs; pub fn load_config(pyproject: &Option) -> Result { match pyproject { Some(pyproject) => Ok(parse_pyproject_toml(pyproject)? .tool .and_then(|tool| tool.ruff) .unwrap_or_default()), None => { eprintln!("No pyproject.toml found."); eprintln!("Falling back to default configuration..."); Ok(Default::default()) } } } #[derive(Debug, PartialEq, Eq, Deserialize, Default)] #[serde(deny_unknown_fields, rename_all = "kebab-case")] pub struct Config { pub line_length: Option, pub exclude: Option>, #[serde(default)] pub extend_exclude: Vec, pub select: Option>, #[serde(default)] pub extend_select: Vec, #[serde(default)] pub ignore: Vec, #[serde(default)] pub extend_ignore: Vec, #[serde(default)] pub per_file_ignores: Vec, pub dummy_variable_rgx: Option, } #[derive(Clone, Debug, PartialEq, Eq)] pub struct StrCheckCodePair { pub pattern: String, pub code: CheckCode, } impl StrCheckCodePair { const EXPECTED_PATTERN: &'static str = ": pattern"; } impl<'de> Deserialize<'de> for StrCheckCodePair { fn deserialize(deserializer: D) -> Result where D: Deserializer<'de>, { let str_result = String::deserialize(deserializer)?; Self::from_str(str_result.as_str()).map_err(|_| { de::Error::invalid_value( de::Unexpected::Str(str_result.as_str()), &Self::EXPECTED_PATTERN, ) }) } } impl FromStr for StrCheckCodePair { type Err = anyhow::Error; fn from_str(string: &str) -> Result { let (pattern_str, code_string) = { let tokens = string.split(':').collect::>(); if tokens.len() != 2 { return Err(anyhow!("Expected {}", Self::EXPECTED_PATTERN)); } (tokens[0], tokens[1]) }; let code = CheckCode::from_str(code_string)?; let pattern = pattern_str.into(); Ok(Self { pattern, code }) } } #[derive(Debug, PartialEq, Eq, Deserialize)] struct Tools { ruff: Option, } #[derive(Debug, PartialEq, Eq, Deserialize)] struct PyProject { tool: Option, } fn parse_pyproject_toml(path: &Path) -> Result { let contents = fs::read_file(path)?; toml::from_str(&contents).map_err(|e| e.into()) } pub fn find_pyproject_toml(path: &Option) -> Option { if let Some(path) = path { let path_pyproject_toml = path.join("pyproject.toml"); if path_pyproject_toml.is_file() { return Some(path_pyproject_toml); } } find_user_pyproject_toml() } fn find_user_pyproject_toml() -> Option { let mut path = dirs::config_dir()?; path.push("ruff"); path.push("pyproject.toml"); if path.is_file() { Some(path) } else { None } } pub fn find_project_root(sources: &[PathBuf]) -> Option { let absolute_sources: Vec = sources .iter() .flat_map(|source| source.absolutize().map(|path| path.to_path_buf())) .collect(); if let Some(prefix) = common_path_all(absolute_sources.iter().map(PathBuf::as_path)) { for directory in prefix.ancestors() { if directory.join(".git").is_dir() { return Some(directory.to_path_buf()); } if directory.join(".hg").is_dir() { return Some(directory.to_path_buf()); } if directory.join("pyproject.toml").is_file() { return Some(directory.to_path_buf()); } } } None } #[cfg(test)] mod tests { use std::env::current_dir; use std::path::PathBuf; use std::str::FromStr; use anyhow::Result; use super::StrCheckCodePair; use crate::checks::CheckCode; use crate::pyproject::{ find_project_root, find_pyproject_toml, parse_pyproject_toml, Config, PyProject, Tools, }; #[test] fn deserialize() -> Result<()> { let pyproject: PyProject = toml::from_str(r#""#)?; assert_eq!(pyproject.tool, None); let pyproject: PyProject = toml::from_str( r#" [tool.black] "#, )?; assert_eq!(pyproject.tool, Some(Tools { ruff: None })); let pyproject: PyProject = toml::from_str( r#" [tool.black] [tool.ruff] "#, )?; assert_eq!( pyproject.tool, Some(Tools { ruff: Some(Config { line_length: None, exclude: None, extend_exclude: vec![], select: None, extend_select: vec![], ignore: vec![], extend_ignore: vec![], per_file_ignores: vec![], dummy_variable_rgx: None, }) }) ); let pyproject: PyProject = toml::from_str( r#" [tool.black] [tool.ruff] line-length = 79 "#, )?; assert_eq!( pyproject.tool, Some(Tools { ruff: Some(Config { line_length: Some(79), exclude: None, extend_exclude: vec![], select: None, extend_select: vec![], ignore: vec![], extend_ignore: vec![], per_file_ignores: vec![], dummy_variable_rgx: None, }) }) ); let pyproject: PyProject = toml::from_str( r#" [tool.black] [tool.ruff] exclude = ["foo.py"] "#, )?; assert_eq!( pyproject.tool, Some(Tools { ruff: Some(Config { line_length: None, exclude: Some(vec!["foo.py".to_string()]), extend_exclude: vec![], select: None, extend_select: vec![], ignore: vec![], extend_ignore: vec![], per_file_ignores: vec![], dummy_variable_rgx: None, }) }) ); let pyproject: PyProject = toml::from_str( r#" [tool.black] [tool.ruff] select = ["E501"] "#, )?; assert_eq!( pyproject.tool, Some(Tools { ruff: Some(Config { line_length: None, exclude: None, extend_exclude: vec![], select: Some(vec![CheckCode::E501]), extend_select: vec![], ignore: vec![], extend_ignore: vec![], per_file_ignores: vec![], dummy_variable_rgx: None, }) }) ); let pyproject: PyProject = toml::from_str( r#" [tool.black] [tool.ruff] extend-select = ["M001"] ignore = ["E501"] "#, )?; assert_eq!( pyproject.tool, Some(Tools { ruff: Some(Config { line_length: None, exclude: None, extend_exclude: vec![], select: None, extend_select: vec![CheckCode::M001], ignore: vec![CheckCode::E501], extend_ignore: vec![], per_file_ignores: vec![], dummy_variable_rgx: None, }) }) ); assert!(toml::from_str::( r#" [tool.black] [tool.ruff] line_length = 79 "#, ) .is_err()); assert!(toml::from_str::( r#" [tool.black] [tool.ruff] select = ["E123"] "#, ) .is_err()); assert!(toml::from_str::( r#" [tool.black] [tool.ruff] line-length = 79 other-attribute = 1 "#, ) .is_err()); Ok(()) } #[test] fn find_and_parse_pyproject_toml() -> Result<()> { let cwd = current_dir()?; let project_root = find_project_root(&[PathBuf::from("resources/test/fixtures/__init__.py")]) .expect("Unable to find project root."); assert_eq!(project_root, cwd.join("resources/test/fixtures")); let path = find_pyproject_toml(&Some(project_root)).expect("Unable to find pyproject.toml."); assert_eq!(path, cwd.join("resources/test/fixtures/pyproject.toml")); let pyproject = parse_pyproject_toml(&path)?; let config = pyproject .tool .and_then(|tool| tool.ruff) .expect("Unable to find tool.ruff."); assert_eq!( config, Config { line_length: Some(88), exclude: None, extend_exclude: vec![ "excluded.py".to_string(), "migrations".to_string(), "directory/also_excluded.py".to_string(), ], select: None, extend_select: vec![], ignore: vec![], extend_ignore: vec![], per_file_ignores: vec![], dummy_variable_rgx: None, } ); Ok(()) } #[test] fn str_check_code_pair_strings() { let result = StrCheckCodePair::from_str("foo:E501"); assert!(result.is_ok()); let result = StrCheckCodePair::from_str("E501:foo"); assert!(result.is_err()); let result = StrCheckCodePair::from_str("E501"); assert!(result.is_err()); let result = StrCheckCodePair::from_str("foo"); assert!(result.is_err()); let result = StrCheckCodePair::from_str("foo:E501:E402"); assert!(result.is_err()); let result = StrCheckCodePair::from_str("**/bar:E501"); assert!(result.is_ok()); let result = StrCheckCodePair::from_str("bar:E502"); assert!(result.is_err()); } }