use std::cmp::Ordering;
use std::collections::BTreeSet;
use std::ffi::OsStr;
use std::path::{Path, PathBuf};
use std::sync::RwLock;
use anyhow::{Context, Result};
use anyhow::{anyhow, bail};
use globset::{Candidate, GlobSet};
use ignore::{DirEntry, Error, ParallelVisitor, WalkBuilder, WalkState};
use itertools::Itertools;
use log::debug;
use matchit::{InsertError, Match, Router};
use path_absolutize::path_dedot;
use path_slash::PathExt;
use rustc_hash::{FxHashMap, FxHashSet};
use ruff_linter::fs;
use ruff_linter::package::PackageRoot;
use ruff_linter::packaging::is_package;
use crate::configuration::Configuration;
use crate::pyproject::settings_toml;
use crate::settings::Settings;
use crate::{FileResolverSettings, pyproject};
#[derive(Debug)]
pub struct PyprojectConfig {
pub strategy: PyprojectDiscoveryStrategy,
pub settings: Settings,
pub path: Option<PathBuf>,
}
impl PyprojectConfig {
pub fn new(
strategy: PyprojectDiscoveryStrategy,
settings: Settings,
path: Option<PathBuf>,
) -> Self {
Self {
strategy,
settings,
path: path.map(fs::normalize_path),
}
}
}
#[derive(Debug, Copy, Clone)]
pub enum PyprojectDiscoveryStrategy {
Fixed,
Hierarchical,
}
impl PyprojectDiscoveryStrategy {
#[inline]
pub const fn is_fixed(self) -> bool {
matches!(self, PyprojectDiscoveryStrategy::Fixed)
}
#[inline]
pub const fn is_hierarchical(self) -> bool {
matches!(self, PyprojectDiscoveryStrategy::Hierarchical)
}
}
#[derive(Copy, Clone)]
pub enum Relativity {
Cwd,
Parent,
}
impl Relativity {
pub fn resolve(self, path: &Path) -> &Path {
match self {
Relativity::Parent => path
.parent()
.expect("Expected pyproject.toml file to be in parent directory"),
Relativity::Cwd => &path_dedot::CWD,
}
}
}
#[derive(Debug)]
pub struct Resolver<'a> {
pyproject_config: &'a PyprojectConfig,
settings: Vec<(Settings, PathBuf)>,
router: Router<usize>,
}
impl<'a> Resolver<'a> {
pub fn new(pyproject_config: &'a PyprojectConfig) -> Self {
Self {
pyproject_config,
settings: Vec::new(),
router: Router::new(),
}
}
#[inline]
pub fn base_settings(&self) -> &Settings {
&self.pyproject_config.settings
}
#[inline]
pub fn is_hierarchical(&self) -> bool {
self.pyproject_config.strategy.is_hierarchical()
}
#[inline]
pub fn force_exclude(&self) -> bool {
self.pyproject_config.settings.file_resolver.force_exclude
}
#[inline]
pub fn respect_gitignore(&self) -> bool {
self.pyproject_config
.settings
.file_resolver
.respect_gitignore
}
fn add(&mut self, path: &Path, settings: Settings, config_path: PathBuf) {
self.settings.push((settings, config_path));
let path = path.to_slash_lossy().replace('{', "{{").replace('}', "}}");
match self
.router
.insert(format!("{path}/{{*filepath}}"), self.settings.len() - 1)
{
Ok(()) => {}
Err(InsertError::Conflict { .. }) => {
return;
}
Err(_) => unreachable!("file paths are escaped before being inserted in the router"),
}
self.router.insert(path, self.settings.len() - 1).unwrap();
}
pub fn resolve(&self, path: &Path) -> &Settings {
self.resolve_with_path(path).0
}
pub fn resolve_with_path(&self, path: &Path) -> (&Settings, Option<&Path>) {
match self.pyproject_config.strategy {
PyprojectDiscoveryStrategy::Fixed => (
&self.pyproject_config.settings,
self.pyproject_config.path.as_deref(),
),
PyprojectDiscoveryStrategy::Hierarchical => self
.router
.at(path.to_slash_lossy().as_ref())
.map(|Match { value, .. }| {
let (settings, config_path) = &self.settings[*value];
(settings, Some(config_path.as_path()))
})
.unwrap_or((
&self.pyproject_config.settings,
self.pyproject_config.path.as_deref(),
)),
}
}
pub fn package_roots(
&'a self,
files: &[&'a Path],
) -> FxHashMap<&'a Path, Option<PackageRoot<'a>>> {
let mut package_cache: FxHashMap<&Path, bool> = FxHashMap::default();
for file in files {
if file.ends_with("__init__.py") {
if let Some(parent) = file.parent() {
package_cache.insert(parent, true);
}
}
}
let has_namespace_packages = self
.settings()
.any(|settings| !settings.linter.namespace_packages.is_empty());
let mut package_roots: FxHashMap<&Path, Option<PackageRoot<'_>>> = FxHashMap::default();
for file in files {
if let Some(package) = file.parent() {
package_roots.entry(package).or_insert_with(|| {
let namespace_packages = if has_namespace_packages {
self.resolve(file).linter.namespace_packages.as_slice()
} else {
&[]
};
detect_package_root_with_cache(package, namespace_packages, &mut package_cache)
.map(|path| PackageRoot::Root { path })
});
}
}
let mut router: Router<&Path> = Router::new();
for root in package_roots
.values()
.flatten()
.copied()
.map(PackageRoot::path)
.collect::<BTreeSet<_>>()
{
let path = root.to_slash_lossy().replace('{', "{{").replace('}', "}}");
if let Ok(matched) = router.at_mut(&path) {
debug!(
"Ignoring nested package root: {} (under {})",
root.display(),
matched.value.display()
);
package_roots.insert(root, Some(PackageRoot::nested(root)));
} else {
let _ = router.insert(format!("{path}/{{*filepath}}"), root);
}
}
package_roots
}
pub fn settings(&self) -> impl Iterator<Item = &Settings> {
std::iter::once(&self.pyproject_config.settings)
.chain(self.settings.iter().map(|(settings, _)| settings))
}
}
fn detect_package_root_with_cache<'a>(
path: &'a Path,
namespace_packages: &[PathBuf],
package_cache: &mut FxHashMap<&'a Path, bool>,
) -> Option<&'a Path> {
let mut current = None;
for parent in path.ancestors() {
if !is_package_with_cache(parent, namespace_packages, package_cache) {
return current;
}
current = Some(parent);
}
current
}
fn is_package_with_cache<'a>(
path: &'a Path,
namespace_packages: &[PathBuf],
package_cache: &mut FxHashMap<&'a Path, bool>,
) -> bool {
*package_cache
.entry(path)
.or_insert_with(|| is_package(path, namespace_packages))
}
pub trait ConfigurationTransformer {
fn transform(&self, config: Configuration) -> Configuration;
}
pub fn resolve_configuration(
initial_config_path: &Path,
transformer: &dyn ConfigurationTransformer,
origin: ConfigurationOrigin,
) -> Result<Configuration> {
let relativity = Relativity::from(origin);
let mut configurations = indexmap::IndexMap::new();
let mut next = Some(fs::normalize_path(initial_config_path));
while let Some(path) = next {
if configurations.contains_key(&path) {
bail!(format!(
"Circular configuration detected: {chain}",
chain = configurations
.keys()
.chain([&path])
.map(|p| format!("`{}`", p.display()))
.join(" extends "),
));
}
let options = pyproject::load_options(&path).with_context(|| {
if configurations.is_empty() {
format!(
"Failed to load configuration `{path}`",
path = path.display()
)
} else {
let chain = configurations
.keys()
.chain([&path])
.map(|p| format!("`{}`", p.display()))
.join(" extends ");
format!(
"Failed to load extended configuration `{path}` ({chain})",
path = path.display()
)
}
})?;
let project_root = relativity.resolve(&path);
let configuration = Configuration::from_options(options, Some(&path), project_root)?;
next = configuration.extend.as_ref().map(|extend| {
fs::normalize_path_to(
extend,
path.parent()
.expect("Expected pyproject.toml file to be in parent directory"),
)
});
configurations.insert(path, configuration);
}
let mut configurations = configurations.into_values();
let mut configuration = configurations.next().unwrap();
for extend in configurations {
configuration = configuration.combine(extend);
}
let configuration = configuration.apply_fallbacks(origin, initial_config_path);
Ok(transformer.transform(configuration))
}
fn resolve_scoped_settings(
pyproject: &Path,
transformer: &dyn ConfigurationTransformer,
origin: ConfigurationOrigin,
) -> Result<(PathBuf, Settings)> {
let relativity = Relativity::from(origin);
let configuration = resolve_configuration(pyproject, transformer, origin)?;
let project_root = relativity.resolve(pyproject);
let settings = configuration.into_settings(project_root)?;
Ok((project_root.to_path_buf(), settings))
}
pub fn resolve_root_settings(
pyproject: &Path,
transformer: &dyn ConfigurationTransformer,
origin: ConfigurationOrigin,
) -> Result<Settings> {
let (_project_root, settings) = resolve_scoped_settings(pyproject, transformer, origin)?;
Ok(settings)
}
#[derive(Debug, Clone, Copy)]
pub enum ConfigurationOrigin {
Unknown,
UserSpecified,
UserSettings,
Ancestor,
}
impl From<ConfigurationOrigin> for Relativity {
fn from(value: ConfigurationOrigin) -> Self {
match value {
ConfigurationOrigin::Unknown => Self::Parent,
ConfigurationOrigin::UserSpecified => Self::Cwd,
ConfigurationOrigin::UserSettings => Self::Cwd,
ConfigurationOrigin::Ancestor => Self::Parent,
}
}
}
pub fn project_files_in_path<'a>(
paths: &[PathBuf],
pyproject_config: &'a PyprojectConfig,
transformer: &(dyn ConfigurationTransformer + Sync),
) -> Result<(Vec<Result<ResolvedFile, ignore::Error>>, Resolver<'a>)> {
let mut paths: Vec<PathBuf> = paths.iter().map(fs::normalize_path).unique().collect();
let mut resolver = Resolver::new(pyproject_config);
let mut seen = FxHashSet::default();
if let Some(config_path) = &pyproject_config.path {
seen.insert(config_path.parent().unwrap());
}
if resolver.is_hierarchical() {
for path in &paths {
for ancestor in path.ancestors() {
if seen.insert(ancestor) {
if let Some(pyproject) = settings_toml(ancestor)? {
let (root, settings) = resolve_scoped_settings(
&pyproject,
transformer,
ConfigurationOrigin::Ancestor,
)?;
resolver.add(&root, settings, pyproject);
break;
}
} else {
break;
}
}
}
}
if resolver.force_exclude() {
paths.retain(|path| !is_file_excluded(path, &resolver));
if paths.is_empty() {
return Ok((vec![], resolver));
}
}
let (first_path, rest_paths) = paths
.split_first()
.ok_or_else(|| anyhow!("Expected at least one path to search for Python files"))?;
let mut builder = WalkBuilder::new(first_path);
if let Ok(cwd) = std::env::current_dir() {
builder.current_dir(cwd);
}
for path in rest_paths {
builder.add(path);
}
builder.standard_filters(resolver.respect_gitignore());
builder.hidden(false);
builder.threads(
std::thread::available_parallelism()
.map_or(1, std::num::NonZeroUsize::get)
.min(12),
);
let walker = builder.build_parallel();
let state = WalkPythonFilesState::new(resolver);
let mut visitor = PythonFilesVisitorBuilder::new(transformer, &state);
walker.visit(&mut visitor);
state.finish()
}
type ResolvedFiles = Vec<Result<ResolvedFile, ignore::Error>>;
struct WalkPythonFilesState<'config> {
is_hierarchical: bool,
merged: std::sync::Mutex<(ResolvedFiles, Result<()>)>,
resolver: RwLock<Resolver<'config>>,
}
impl<'config> WalkPythonFilesState<'config> {
fn new(resolver: Resolver<'config>) -> Self {
Self {
is_hierarchical: resolver.is_hierarchical(),
merged: std::sync::Mutex::new((Vec::new(), Ok(()))),
resolver: RwLock::new(resolver),
}
}
fn finish(self) -> Result<(Vec<Result<ResolvedFile, ignore::Error>>, Resolver<'config>)> {
let (files, error) = self.merged.into_inner().unwrap();
error?;
let deduplicated_files = deduplicate_files(files);
Ok((deduplicated_files, self.resolver.into_inner().unwrap()))
}
}
fn deduplicate_files(mut files: ResolvedFiles) -> ResolvedFiles {
files.sort_by(|a, b| match (a, b) {
(Ok(a_file), Ok(b_file)) => a_file.cmp(b_file),
(Ok(_), Err(_)) => Ordering::Less,
(Err(_), Ok(_)) => Ordering::Greater,
(Err(_), Err(_)) => Ordering::Equal,
});
files.dedup_by(|a, b| match (a, b) {
(Ok(a_file), Ok(b_file)) => a_file.path() == b_file.path(),
_ => false,
});
files
}
struct PythonFilesVisitorBuilder<'s, 'config> {
state: &'s WalkPythonFilesState<'config>,
transformer: &'s (dyn ConfigurationTransformer + Sync),
}
impl<'s, 'config> PythonFilesVisitorBuilder<'s, 'config> {
fn new(
transformer: &'s (dyn ConfigurationTransformer + Sync),
state: &'s WalkPythonFilesState<'config>,
) -> Self {
Self { state, transformer }
}
}
struct PythonFilesVisitor<'s, 'config> {
local_files: Vec<Result<ResolvedFile, ignore::Error>>,
local_error: Result<()>,
global: &'s WalkPythonFilesState<'config>,
transformer: &'s (dyn ConfigurationTransformer + Sync),
}
impl<'config, 's> ignore::ParallelVisitorBuilder<'s> for PythonFilesVisitorBuilder<'s, 'config>
where
'config: 's,
{
fn build(&mut self) -> Box<dyn ignore::ParallelVisitor + 's> {
Box::new(PythonFilesVisitor {
local_files: vec![],
local_error: Ok(()),
global: self.state,
transformer: self.transformer,
})
}
}
impl ParallelVisitor for PythonFilesVisitor<'_, '_> {
fn visit(&mut self, result: std::result::Result<DirEntry, Error>) -> WalkState {
if let Ok(entry) = &result {
if entry.depth() > 0 {
let path = entry.path();
let resolver = self.global.resolver.read().unwrap();
let settings = resolver.resolve(path);
if let Some(file_name) = path.file_name() {
let file_path = Candidate::new(path);
let file_basename = Candidate::new(file_name);
if match_candidate_exclusion(
&file_path,
&file_basename,
&settings.file_resolver.exclude,
) {
debug!("Ignored path via `exclude`: {path:?}");
return WalkState::Skip;
} else if match_candidate_exclusion(
&file_path,
&file_basename,
&settings.file_resolver.extend_exclude,
) {
debug!("Ignored path via `extend-exclude`: {path:?}");
return WalkState::Skip;
}
} else {
debug!("Ignored path due to error in parsing: {path:?}");
return WalkState::Skip;
}
}
}
if self.global.is_hierarchical {
if let Ok(entry) = &result {
if entry
.file_type()
.is_some_and(|file_type| file_type.is_dir())
{
match settings_toml(entry.path()) {
Ok(Some(pyproject)) => match resolve_scoped_settings(
&pyproject,
self.transformer,
ConfigurationOrigin::Ancestor,
) {
Ok((root, settings)) => {
self.global
.resolver
.write()
.unwrap()
.add(&root, settings, pyproject);
}
Err(err) => {
self.local_error = Err(err);
return WalkState::Quit;
}
},
Ok(None) => {}
Err(err) => {
self.local_error = Err(err);
return WalkState::Quit;
}
}
}
}
}
match result {
Ok(entry) => {
let resolved = if entry.file_type().is_none_or(|ft| ft.is_dir()) {
None
} else if entry.depth() == 0 {
Some(ResolvedFile::Root(entry.into_path()))
} else {
let path = entry.path();
let resolver = self.global.resolver.read().unwrap();
let settings = resolver.resolve(path);
if settings.file_resolver.include.is_match(path) {
debug!("Included path via `include`: {path:?}");
Some(ResolvedFile::Nested(entry.into_path()))
} else if settings.file_resolver.extend_include.is_match(path) {
debug!("Included path via `extend-include`: {path:?}");
Some(ResolvedFile::Nested(entry.into_path()))
} else {
None
}
};
if let Some(resolved) = resolved {
self.local_files.push(Ok(resolved));
}
}
Err(err) => {
self.local_files.push(Err(err));
}
}
WalkState::Continue
}
}
impl Drop for PythonFilesVisitor<'_, '_> {
fn drop(&mut self) {
let mut merged = self.global.merged.lock().unwrap();
let (files, error) = &mut *merged;
if files.is_empty() {
*files = std::mem::take(&mut self.local_files);
} else {
files.append(&mut self.local_files);
}
let local_error = std::mem::replace(&mut self.local_error, Ok(()));
if error.is_ok() {
*error = local_error;
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Ord, PartialOrd)]
pub enum ResolvedFile {
Root(PathBuf),
Nested(PathBuf),
}
impl ResolvedFile {
pub fn into_path(self) -> PathBuf {
match self {
ResolvedFile::Root(path) => path,
ResolvedFile::Nested(path) => path,
}
}
pub fn path(&self) -> &Path {
match self {
ResolvedFile::Root(root) => root.as_path(),
ResolvedFile::Nested(root) => root.as_path(),
}
}
pub fn file_name(&self) -> &OsStr {
let path = self.path();
path.file_name().unwrap_or(path.as_os_str())
}
pub fn is_root(&self) -> bool {
matches!(self, ResolvedFile::Root(_))
}
}
pub fn project_file_at_path(
path: &Path,
resolver: &mut Resolver,
transformer: &dyn ConfigurationTransformer,
) -> Result<bool> {
let path = fs::normalize_path(path);
if resolver.is_hierarchical() {
for ancestor in path.ancestors() {
if let Some(pyproject) = settings_toml(ancestor)? {
let (root, settings) =
resolve_scoped_settings(&pyproject, transformer, ConfigurationOrigin::Unknown)?;
resolver.add(&root, settings, pyproject);
break;
}
}
}
Ok(!is_file_excluded(&path, resolver))
}
fn is_file_excluded(path: &Path, resolver: &Resolver) -> bool {
for path in path.ancestors() {
let settings = resolver.resolve(path);
if let Some(file_name) = path.file_name() {
let file_path = Candidate::new(path);
let file_basename = Candidate::new(file_name);
if match_candidate_exclusion(
&file_path,
&file_basename,
&settings.file_resolver.exclude,
) {
debug!("Ignored path via `exclude`: {path:?}");
return true;
} else if match_candidate_exclusion(
&file_path,
&file_basename,
&settings.file_resolver.extend_exclude,
) {
debug!("Ignored path via `extend-exclude`: {path:?}");
return true;
}
} else {
break;
}
if path == settings.file_resolver.project_root {
break;
}
}
false
}
#[inline]
pub fn match_exclusion<P: AsRef<Path>, R: AsRef<Path>>(
file_path: P,
file_basename: R,
exclusion: &GlobSet,
) -> bool {
match_candidate_exclusion(
&Candidate::new(file_path.as_ref()),
&Candidate::new(file_basename.as_ref()),
exclusion,
)
}
pub fn match_candidate_exclusion(
file_path: &Candidate,
file_basename: &Candidate,
exclusion: &GlobSet,
) -> bool {
if exclusion.is_empty() {
return false;
}
exclusion.is_match_candidate(file_path) || exclusion.is_match_candidate(file_basename)
}
#[derive(Debug, Copy, Clone)]
pub enum ExclusionKind {
Exclude,
ExtendExclude,
LintExclude,
FormatExclude,
}
impl std::fmt::Display for ExclusionKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ExclusionKind::Exclude => write!(f, "exclude"),
ExclusionKind::ExtendExclude => write!(f, "extend-exclude"),
ExclusionKind::LintExclude => write!(f, "lint.exclude"),
ExclusionKind::FormatExclude => write!(f, "lint.extend-exclude"),
}
}
}
pub fn match_any_exclusion(
path: &Path,
resolver_settings: &FileResolverSettings,
lint_exclude: Option<&GlobSet>,
format_exclude: Option<&GlobSet>,
) -> Option<ExclusionKind> {
for path in path.ancestors() {
if let Some(basename) = path.file_name() {
let path = Candidate::new(path);
let basename = Candidate::new(basename);
if match_candidate_exclusion(&path, &basename, &resolver_settings.exclude) {
return Some(ExclusionKind::Exclude);
}
if match_candidate_exclusion(&path, &basename, &resolver_settings.extend_exclude) {
return Some(ExclusionKind::ExtendExclude);
}
if let Some(lint_exclude) = lint_exclude {
if match_candidate_exclusion(&path, &basename, lint_exclude) {
return Some(ExclusionKind::LintExclude);
}
}
if let Some(format_exclude) = format_exclude {
if match_candidate_exclusion(&path, &basename, format_exclude) {
return Some(ExclusionKind::FormatExclude);
}
}
}
if path == resolver_settings.project_root {
break;
}
}
None
}
#[derive(Debug, Copy, Clone)]
pub enum InclusionKind {
Include,
ExtendInclude,
}
impl std::fmt::Display for InclusionKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
InclusionKind::Include => write!(f, "include"),
InclusionKind::ExtendInclude => write!(f, "extend-include"),
}
}
}
pub fn match_any_inclusion(
path: &Path,
resolver_settings: &FileResolverSettings,
) -> Option<InclusionKind> {
if resolver_settings.include.is_match(path) {
Some(InclusionKind::Include)
} else if resolver_settings.extend_include.is_match(path) {
Some(InclusionKind::ExtendInclude)
} else {
None
}
}
#[cfg(test)]
mod tests {
use std::fs::{File, create_dir};
use std::path::Path;
use anyhow::Result;
use globset::GlobSet;
use itertools::Itertools;
use path_absolutize::Absolutize;
use tempfile::TempDir;
use ruff_linter::settings::{
TargetVersion,
types::{FilePattern, GlobPath, PythonVersion},
};
use crate::configuration::Configuration;
use crate::pyproject::find_settings_toml;
use crate::resolver::{
ConfigurationOrigin, ConfigurationTransformer, PyprojectConfig, PyprojectDiscoveryStrategy,
ResolvedFile, Resolver, is_file_excluded, match_exclusion, project_files_in_path,
resolve_root_settings,
};
use crate::settings::Settings;
use crate::tests::test_resource_path;
struct NoOpTransformer;
impl ConfigurationTransformer for NoOpTransformer {
fn transform(&self, config: Configuration) -> Configuration {
config
}
}
#[test]
fn rooted_exclusion() -> Result<()> {
let package_root = test_resource_path("package");
let pyproject_config = PyprojectConfig::new(
PyprojectDiscoveryStrategy::Hierarchical,
resolve_root_settings(
&find_settings_toml(&package_root)?.unwrap(),
&NoOpTransformer,
ConfigurationOrigin::Ancestor,
)?,
None,
);
let resolver = Resolver::new(&pyproject_config);
assert!(!is_file_excluded(
&package_root.join("src/app.py"),
&resolver,
));
assert!(is_file_excluded(
&package_root.join("resources/ignored.py"),
&resolver,
));
Ok(())
}
#[test]
fn find_python_files() -> Result<()> {
let tmp_dir = TempDir::new()?;
let root = tmp_dir.path();
let file1 = root.join("file1.py");
let dir1 = root.join("dir1.py");
let file2 = dir1.join("file2.py");
let dir2 = root.join("dir2.py");
File::create(&file1)?;
create_dir(dir1)?;
File::create(&file2)?;
create_dir(dir2)?;
let (paths, _) = project_files_in_path(
&[root.to_path_buf()],
&PyprojectConfig::new(PyprojectDiscoveryStrategy::Fixed, Settings::default(), None),
&NoOpTransformer,
)?;
let paths = paths
.into_iter()
.flatten()
.map(ResolvedFile::into_path)
.sorted()
.collect::<Vec<_>>();
assert_eq!(paths, [file2, file1]);
Ok(())
}
fn make_exclusion(file_pattern: FilePattern) -> GlobSet {
let mut builder = globset::GlobSetBuilder::new();
file_pattern.add_to(&mut builder).unwrap();
builder.build().unwrap()
}
#[test]
fn exclusions() {
let project_root = Path::new("/tmp/");
let path = Path::new("foo").absolutize_from(project_root).unwrap();
let exclude =
FilePattern::User("foo".to_string(), GlobPath::normalize("foo", project_root));
let file_path = &path;
let file_basename = path.file_name().unwrap();
assert!(match_exclusion(
file_path,
file_basename,
&make_exclusion(exclude),
));
let path = Path::new("foo/bar").absolutize_from(project_root).unwrap();
let exclude =
FilePattern::User("bar".to_string(), GlobPath::normalize("bar", project_root));
let file_path = &path;
let file_basename = path.file_name().unwrap();
assert!(match_exclusion(
file_path,
file_basename,
&make_exclusion(exclude),
));
let path = Path::new("foo/bar/baz.py")
.absolutize_from(project_root)
.unwrap();
let exclude = FilePattern::User(
"baz.py".to_string(),
GlobPath::normalize("baz.py", project_root),
);
let file_path = &path;
let file_basename = path.file_name().unwrap();
assert!(match_exclusion(
file_path,
file_basename,
&make_exclusion(exclude),
));
let path = Path::new("foo/bar").absolutize_from(project_root).unwrap();
let exclude = FilePattern::User(
"foo/bar".to_string(),
GlobPath::normalize("foo/bar", project_root),
);
let file_path = &path;
let file_basename = path.file_name().unwrap();
assert!(match_exclusion(
file_path,
file_basename,
&make_exclusion(exclude),
));
let path = Path::new("foo/bar/baz.py")
.absolutize_from(project_root)
.unwrap();
let exclude = FilePattern::User(
"foo/bar/baz.py".to_string(),
GlobPath::normalize("foo/bar/baz.py", project_root),
);
let file_path = &path;
let file_basename = path.file_name().unwrap();
assert!(match_exclusion(
file_path,
file_basename,
&make_exclusion(exclude),
));
let path = Path::new("foo/bar/baz.py")
.absolutize_from(project_root)
.unwrap();
let exclude = FilePattern::User(
"foo/bar/*.py".to_string(),
GlobPath::normalize("foo/bar/*.py", project_root),
);
let file_path = &path;
let file_basename = path.file_name().unwrap();
assert!(match_exclusion(
file_path,
file_basename,
&make_exclusion(exclude),
));
let path = Path::new("foo/bar/baz.py")
.absolutize_from(project_root)
.unwrap();
let exclude =
FilePattern::User("baz".to_string(), GlobPath::normalize("baz", project_root));
let file_path = &path;
let file_basename = path.file_name().unwrap();
assert!(!match_exclusion(
file_path,
file_basename,
&make_exclusion(exclude),
));
}
#[test]
fn extend_respects_target_version() -> Result<()> {
let tmp_dir = TempDir::new()?;
let root = tmp_dir.path();
let ruff_toml = root.join("ruff.toml");
std::fs::write(&ruff_toml, "target-version = \"py310\"")?;
let dot_ruff_toml = root.join(".ruff.toml");
std::fs::write(&dot_ruff_toml, "extend = \"ruff.toml\"")?;
let pyproject_toml = root.join("pyproject.toml");
std::fs::write(
&pyproject_toml,
r#"[project]
name = "repro-ruff"
version = "0.1.0"
requires-python = ">=3.13"
"#,
)?;
let main_py = root.join("main.py");
std::fs::write(
&main_py,
r#"from typing import TypeAlias
A: TypeAlias = str | int
"#,
)?;
let settings = resolve_root_settings(
&dot_ruff_toml,
&NoOpTransformer,
ConfigurationOrigin::Ancestor,
)?;
assert_eq!(
settings.linter.unresolved_target_version,
TargetVersion(Some(PythonVersion::Py310.into()))
);
Ok(())
}
}