mirror of
https://github.com/astral-sh/ruff.git
synced 2025-09-30 13:51:37 +00:00
1073 lines
34 KiB
Rust
1073 lines
34 KiB
Rust
use std::fmt::Debug;
|
|
use std::fs::{self, File};
|
|
use std::hash::Hasher;
|
|
use std::io::{self, BufReader, BufWriter, Write};
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::atomic::{AtomicU64, Ordering};
|
|
use std::sync::Mutex;
|
|
use std::time::{Duration, SystemTime};
|
|
|
|
use anyhow::{Context, Result};
|
|
use filetime::FileTime;
|
|
use itertools::Itertools;
|
|
use log::{debug, error};
|
|
use rayon::iter::ParallelIterator;
|
|
use rayon::iter::{IntoParallelIterator, ParallelBridge};
|
|
use rustc_hash::FxHashMap;
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
use ruff_cache::{CacheKey, CacheKeyHasher};
|
|
use ruff_diagnostics::{DiagnosticKind, Fix};
|
|
use ruff_linter::message::Message;
|
|
use ruff_linter::{warn_user, VERSION};
|
|
use ruff_macros::CacheKey;
|
|
use ruff_notebook::NotebookIndex;
|
|
use ruff_python_ast::imports::ImportMap;
|
|
use ruff_source_file::SourceFileBuilder;
|
|
use ruff_text_size::{TextRange, TextSize};
|
|
use ruff_workspace::resolver::{PyprojectConfig, PyprojectDiscoveryStrategy, Resolver};
|
|
use ruff_workspace::Settings;
|
|
|
|
use crate::cache;
|
|
use crate::diagnostics::Diagnostics;
|
|
|
|
/// [`Path`] that is relative to the package root in [`PackageCache`].
|
|
pub(crate) type RelativePath = Path;
|
|
/// [`PathBuf`] that is relative to the package root in [`PackageCache`].
|
|
pub(crate) type RelativePathBuf = PathBuf;
|
|
|
|
#[derive(CacheKey)]
|
|
pub(crate) struct FileCacheKey {
|
|
/// Timestamp when the file was last modified before the (cached) check.
|
|
file_last_modified: FileTime,
|
|
/// Permissions of the file before the (cached) check.
|
|
file_permissions_mode: u32,
|
|
}
|
|
|
|
impl FileCacheKey {
|
|
pub(crate) fn from_path(path: &Path) -> io::Result<FileCacheKey> {
|
|
// Construct a cache key for the file
|
|
let metadata = path.metadata()?;
|
|
|
|
#[cfg(unix)]
|
|
let permissions = {
|
|
use std::os::unix::fs::PermissionsExt;
|
|
metadata.permissions().mode()
|
|
};
|
|
#[cfg(windows)]
|
|
let permissions: u32 = metadata.permissions().readonly().into();
|
|
|
|
Ok(FileCacheKey {
|
|
file_last_modified: FileTime::from_last_modification_time(&metadata),
|
|
file_permissions_mode: permissions,
|
|
})
|
|
}
|
|
}
|
|
|
|
/// Cache.
|
|
///
|
|
/// `Cache` holds everything required to display the diagnostics for a single
|
|
/// package. The on-disk representation is represented in [`PackageCache`] (and
|
|
/// related) types.
|
|
///
|
|
/// This type manages the cache file, reading it from disk and writing it back
|
|
/// to disk (if required).
|
|
#[derive(Debug)]
|
|
pub(crate) struct Cache {
|
|
/// Location of the cache.
|
|
path: PathBuf,
|
|
/// Package cache read from disk.
|
|
package: PackageCache,
|
|
/// Changes made compared to the (current) `package`.
|
|
///
|
|
/// Files that are linted, but are not in `package.files` or are in
|
|
/// `package.files` but are outdated. This gets merged with `package.files`
|
|
/// when the cache is written back to disk in [`Cache::store`].
|
|
changes: Mutex<Vec<Change>>,
|
|
/// The "current" timestamp used as cache for the updates of
|
|
/// [`FileCache::last_seen`]
|
|
last_seen_cache: u64,
|
|
}
|
|
|
|
impl Cache {
|
|
/// Open or create a new cache.
|
|
///
|
|
/// `package_root` is the path to root of the package that is contained
|
|
/// within this cache and must be canonicalized (to avoid considering `./`
|
|
/// and `../project` being different).
|
|
///
|
|
/// Finally `settings` is used to ensure we don't open a cache for different
|
|
/// settings. It also defines the directory where to store the cache.
|
|
pub(crate) fn open(package_root: PathBuf, settings: &Settings) -> Self {
|
|
debug_assert!(package_root.is_absolute(), "package root not canonicalized");
|
|
|
|
let key = format!("{}", cache_key(&package_root, settings));
|
|
let path = PathBuf::from_iter([&settings.cache_dir, Path::new(VERSION), Path::new(&key)]);
|
|
|
|
let file = match File::open(&path) {
|
|
Ok(file) => file,
|
|
Err(err) if err.kind() == io::ErrorKind::NotFound => {
|
|
// No cache exist yet, return an empty cache.
|
|
return Cache::empty(path, package_root);
|
|
}
|
|
Err(err) => {
|
|
warn_user!("Failed to open cache file '{}': {err}", path.display());
|
|
return Cache::empty(path, package_root);
|
|
}
|
|
};
|
|
|
|
let mut package: PackageCache = match bincode::deserialize_from(BufReader::new(file)) {
|
|
Ok(package) => package,
|
|
Err(err) => {
|
|
warn_user!("Failed parse cache file '{}': {err}", path.display());
|
|
return Cache::empty(path, package_root);
|
|
}
|
|
};
|
|
|
|
// Sanity check.
|
|
if package.package_root != package_root {
|
|
warn_user!(
|
|
"Different package root in cache: expected '{}', got '{}'",
|
|
package_root.display(),
|
|
package.package_root.display(),
|
|
);
|
|
package.files.clear();
|
|
}
|
|
Cache::new(path, package)
|
|
}
|
|
|
|
/// Create an empty `Cache`.
|
|
fn empty(path: PathBuf, package_root: PathBuf) -> Self {
|
|
let package = PackageCache {
|
|
package_root,
|
|
files: FxHashMap::default(),
|
|
};
|
|
Cache::new(path, package)
|
|
}
|
|
|
|
#[allow(clippy::cast_possible_truncation)]
|
|
fn new(path: PathBuf, package: PackageCache) -> Self {
|
|
Cache {
|
|
path,
|
|
package,
|
|
changes: Mutex::new(Vec::new()),
|
|
// SAFETY: this will be truncated to the year ~2554 (so don't use
|
|
// this code after that!).
|
|
last_seen_cache: SystemTime::UNIX_EPOCH.elapsed().unwrap().as_millis() as u64,
|
|
}
|
|
}
|
|
|
|
/// Applies the pending changes and persists the cache to disk, if it has been changed.
|
|
pub(crate) fn persist(mut self) -> Result<()> {
|
|
if !self.save() {
|
|
// No changes made, no need to write the same cache file back to
|
|
// disk.
|
|
return Ok(());
|
|
}
|
|
|
|
let file = File::create(&self.path)
|
|
.with_context(|| format!("Failed to create cache file '{}'", self.path.display()))?;
|
|
let writer = BufWriter::new(file);
|
|
bincode::serialize_into(writer, &self.package).with_context(|| {
|
|
format!(
|
|
"Failed to serialise cache to file '{}'",
|
|
self.path.display()
|
|
)
|
|
})
|
|
}
|
|
|
|
/// Applies the pending changes without storing the cache to disk.
|
|
#[allow(clippy::cast_possible_truncation)]
|
|
pub(crate) fn save(&mut self) -> bool {
|
|
/// Maximum duration for which we keep a file in cache that hasn't been seen.
|
|
const MAX_LAST_SEEN: Duration = Duration::from_secs(30 * 24 * 60 * 60); // 30 days.
|
|
|
|
let changes = std::mem::take(self.changes.get_mut().unwrap());
|
|
if changes.is_empty() {
|
|
return false;
|
|
}
|
|
|
|
// Remove cached files that we haven't seen in a while.
|
|
let now = self.last_seen_cache;
|
|
self.package.files.retain(|_, file| {
|
|
// SAFETY: this will be truncated to the year ~2554.
|
|
(now - *file.last_seen.get_mut()) <= MAX_LAST_SEEN.as_millis() as u64
|
|
});
|
|
|
|
// Apply any changes made and keep track of when we last saw files.
|
|
for change in changes {
|
|
let entry = self
|
|
.package
|
|
.files
|
|
.entry(change.path)
|
|
.and_modify(|existing| {
|
|
if existing.key != change.new_key {
|
|
// Reset the data if the key change.
|
|
existing.data = FileCacheData::default();
|
|
}
|
|
|
|
existing.key = change.new_key;
|
|
existing
|
|
.last_seen
|
|
.store(self.last_seen_cache, Ordering::Relaxed);
|
|
})
|
|
.or_insert_with(|| FileCache {
|
|
key: change.new_key,
|
|
last_seen: AtomicU64::new(self.last_seen_cache),
|
|
data: FileCacheData::default(),
|
|
});
|
|
|
|
change.new_data.apply(&mut entry.data);
|
|
}
|
|
|
|
true
|
|
}
|
|
|
|
/// Returns the relative path based on `path` and the package root.
|
|
///
|
|
/// Returns `None` if `path` is not within the package.
|
|
pub(crate) fn relative_path<'a>(&self, path: &'a Path) -> Option<&'a RelativePath> {
|
|
path.strip_prefix(&self.package.package_root).ok()
|
|
}
|
|
|
|
/// Get the cached results for a single file at relative `path`. This
|
|
/// uses `key` to determine if the results are still accurate.
|
|
/// (i.e. if the file hasn't been modified since the cached run).
|
|
///
|
|
/// This returns `None` if `key` differs from the cached key or if the
|
|
/// cache doesn't contain results for the file.
|
|
pub(crate) fn get(&self, path: &RelativePath, key: &FileCacheKey) -> Option<&FileCache> {
|
|
let file = self.package.files.get(path)?;
|
|
|
|
let mut hasher = CacheKeyHasher::new();
|
|
key.cache_key(&mut hasher);
|
|
|
|
// Make sure the file hasn't changed since the cached run.
|
|
if file.key != hasher.finish() {
|
|
return None;
|
|
}
|
|
|
|
file.last_seen.store(self.last_seen_cache, Ordering::SeqCst);
|
|
|
|
Some(file)
|
|
}
|
|
|
|
pub(crate) fn is_formatted(&self, path: &RelativePath, key: &FileCacheKey) -> bool {
|
|
self.get(path, key)
|
|
.is_some_and(|entry| entry.data.formatted)
|
|
}
|
|
|
|
/// Add or update a file cache at `path` relative to the package root.
|
|
fn update(&self, path: RelativePathBuf, key: &FileCacheKey, data: ChangeData) {
|
|
let mut hasher = CacheKeyHasher::new();
|
|
key.cache_key(&mut hasher);
|
|
|
|
self.changes.lock().unwrap().push(Change {
|
|
path,
|
|
new_key: hasher.finish(),
|
|
new_data: data,
|
|
});
|
|
}
|
|
|
|
pub(crate) fn update_lint(
|
|
&self,
|
|
path: RelativePathBuf,
|
|
key: &FileCacheKey,
|
|
data: LintCacheData,
|
|
) {
|
|
self.update(path, key, ChangeData::Lint(data));
|
|
}
|
|
|
|
pub(crate) fn set_formatted(&self, path: RelativePathBuf, key: &FileCacheKey) {
|
|
self.update(path, key, ChangeData::Formatted);
|
|
}
|
|
}
|
|
|
|
/// On disk representation of a cache of a package.
|
|
#[derive(Deserialize, Debug, Serialize)]
|
|
struct PackageCache {
|
|
/// Path to the root of the package.
|
|
///
|
|
/// Usually this is a directory, but it can also be a single file in case of
|
|
/// single file "packages", e.g. scripts.
|
|
package_root: PathBuf,
|
|
/// Mapping of source file path to it's cached data.
|
|
files: FxHashMap<RelativePathBuf, FileCache>,
|
|
}
|
|
|
|
/// On disk representation of the cache per source file.
|
|
#[derive(Deserialize, Debug, Serialize)]
|
|
pub(crate) struct FileCache {
|
|
/// Key that determines if the cached item is still valid.
|
|
key: u64,
|
|
/// Timestamp when we last linted this file.
|
|
///
|
|
/// Represented as the number of milliseconds since Unix epoch. This will
|
|
/// break in 1970 + ~584 years (~2554).
|
|
last_seen: AtomicU64,
|
|
|
|
data: FileCacheData,
|
|
}
|
|
|
|
impl FileCache {
|
|
/// Convert the file cache into `Diagnostics`, using `path` as file name.
|
|
pub(crate) fn to_diagnostics(&self, path: &Path) -> Option<Diagnostics> {
|
|
self.data.lint.as_ref().map(|lint| {
|
|
let messages = if lint.messages.is_empty() {
|
|
Vec::new()
|
|
} else {
|
|
let file = SourceFileBuilder::new(path.to_string_lossy(), &*lint.source).finish();
|
|
lint.messages
|
|
.iter()
|
|
.map(|msg| Message {
|
|
kind: msg.kind.clone(),
|
|
range: msg.range,
|
|
fix: msg.fix.clone(),
|
|
file: file.clone(),
|
|
noqa_offset: msg.noqa_offset,
|
|
})
|
|
.collect()
|
|
};
|
|
let notebook_indexes = if let Some(notebook_index) = lint.notebook_index.as_ref() {
|
|
FxHashMap::from_iter([(path.to_string_lossy().to_string(), notebook_index.clone())])
|
|
} else {
|
|
FxHashMap::default()
|
|
};
|
|
Diagnostics::new(messages, lint.imports.clone(), notebook_indexes)
|
|
})
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Default, Deserialize, Serialize)]
|
|
struct FileCacheData {
|
|
lint: Option<LintCacheData>,
|
|
formatted: bool,
|
|
}
|
|
|
|
/// Returns a hash key based on the `package_root`, `settings` and the crate
|
|
/// version.
|
|
fn cache_key(package_root: &Path, settings: &Settings) -> u64 {
|
|
let mut hasher = CacheKeyHasher::new();
|
|
package_root.cache_key(&mut hasher);
|
|
settings.cache_key(&mut hasher);
|
|
|
|
hasher.finish()
|
|
}
|
|
|
|
/// Initialize the cache at the specified `Path`.
|
|
pub(crate) fn init(path: &Path) -> Result<()> {
|
|
// Create the cache directories.
|
|
fs::create_dir_all(path.join(VERSION))?;
|
|
|
|
// Add the CACHEDIR.TAG.
|
|
if !cachedir::is_tagged(path)? {
|
|
cachedir::add_tag(path)?;
|
|
}
|
|
|
|
// Add the .gitignore.
|
|
let gitignore_path = path.join(".gitignore");
|
|
if !gitignore_path.exists() {
|
|
let mut file = fs::File::create(gitignore_path)?;
|
|
file.write_all(b"*")?;
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[derive(Deserialize, Debug, Serialize, PartialEq)]
|
|
pub(crate) struct LintCacheData {
|
|
/// Imports made.
|
|
pub(super) imports: ImportMap,
|
|
/// Diagnostic messages.
|
|
pub(super) messages: Vec<CacheMessage>,
|
|
/// Source code of the file.
|
|
///
|
|
/// # Notes
|
|
///
|
|
/// This will be empty if `messages` is empty.
|
|
pub(super) source: String,
|
|
/// Notebook index if this file is a Jupyter Notebook.
|
|
pub(super) notebook_index: Option<NotebookIndex>,
|
|
}
|
|
|
|
impl LintCacheData {
|
|
pub(crate) fn from_messages(
|
|
messages: &[Message],
|
|
imports: ImportMap,
|
|
notebook_index: Option<NotebookIndex>,
|
|
) -> Self {
|
|
let source = if let Some(msg) = messages.first() {
|
|
msg.file.source_text().to_owned()
|
|
} else {
|
|
String::new() // No messages, no need to keep the source!
|
|
};
|
|
|
|
let messages = messages
|
|
.iter()
|
|
.map(|msg| {
|
|
// Make sure that all message use the same source file.
|
|
assert_eq!(
|
|
msg.file,
|
|
messages.first().unwrap().file,
|
|
"message uses a different source file"
|
|
);
|
|
CacheMessage {
|
|
kind: msg.kind.clone(),
|
|
range: msg.range,
|
|
fix: msg.fix.clone(),
|
|
noqa_offset: msg.noqa_offset,
|
|
}
|
|
})
|
|
.collect();
|
|
|
|
Self {
|
|
imports,
|
|
messages,
|
|
source,
|
|
notebook_index,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// On disk representation of a diagnostic message.
|
|
#[derive(Deserialize, Debug, Serialize, PartialEq)]
|
|
pub(super) struct CacheMessage {
|
|
kind: DiagnosticKind,
|
|
/// Range into the message's [`FileCache::source`].
|
|
range: TextRange,
|
|
fix: Option<Fix>,
|
|
noqa_offset: TextSize,
|
|
}
|
|
|
|
pub(crate) trait PackageCaches {
|
|
fn get(&self, package_root: &Path) -> Option<&Cache>;
|
|
|
|
fn persist(self) -> anyhow::Result<()>;
|
|
}
|
|
|
|
impl<T> PackageCaches for Option<T>
|
|
where
|
|
T: PackageCaches,
|
|
{
|
|
fn get(&self, package_root: &Path) -> Option<&Cache> {
|
|
match self {
|
|
None => None,
|
|
Some(caches) => caches.get(package_root),
|
|
}
|
|
}
|
|
|
|
fn persist(self) -> Result<()> {
|
|
match self {
|
|
None => Ok(()),
|
|
Some(caches) => caches.persist(),
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(crate) struct PackageCacheMap<'a>(FxHashMap<&'a Path, Cache>);
|
|
|
|
impl<'a> PackageCacheMap<'a> {
|
|
pub(crate) fn init(
|
|
pyproject_config: &PyprojectConfig,
|
|
package_roots: &FxHashMap<&'a Path, Option<&'a Path>>,
|
|
resolver: &Resolver,
|
|
) -> Self {
|
|
fn init_cache(path: &Path) {
|
|
if let Err(e) = cache::init(path) {
|
|
error!("Failed to initialize cache at {}: {e:?}", path.display());
|
|
}
|
|
}
|
|
|
|
match pyproject_config.strategy {
|
|
PyprojectDiscoveryStrategy::Fixed => {
|
|
init_cache(&pyproject_config.settings.cache_dir);
|
|
}
|
|
PyprojectDiscoveryStrategy::Hierarchical => {
|
|
for settings in
|
|
std::iter::once(&pyproject_config.settings).chain(resolver.settings())
|
|
{
|
|
init_cache(&settings.cache_dir);
|
|
}
|
|
}
|
|
}
|
|
|
|
Self(
|
|
package_roots
|
|
.iter()
|
|
.map(|(package, package_root)| package_root.unwrap_or(package))
|
|
.unique()
|
|
.par_bridge()
|
|
.map(|cache_root| {
|
|
let settings = resolver.resolve(cache_root, pyproject_config);
|
|
let cache = Cache::open(cache_root.to_path_buf(), settings);
|
|
(cache_root, cache)
|
|
})
|
|
.collect(),
|
|
)
|
|
}
|
|
}
|
|
|
|
impl PackageCaches for PackageCacheMap<'_> {
|
|
fn get(&self, package_root: &Path) -> Option<&Cache> {
|
|
let cache = self.0.get(package_root);
|
|
|
|
if cache.is_none() {
|
|
debug!("No cache found for {}", package_root.display());
|
|
}
|
|
|
|
cache
|
|
}
|
|
|
|
fn persist(self) -> Result<()> {
|
|
self.0
|
|
.into_par_iter()
|
|
.try_for_each(|(_, cache)| cache.persist())
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct Change {
|
|
path: PathBuf,
|
|
new_key: u64,
|
|
new_data: ChangeData,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
enum ChangeData {
|
|
Lint(LintCacheData),
|
|
Formatted,
|
|
}
|
|
|
|
impl ChangeData {
|
|
fn apply(self, data: &mut FileCacheData) {
|
|
match self {
|
|
ChangeData::Lint(new_lint) => {
|
|
data.lint = Some(new_lint);
|
|
}
|
|
ChangeData::Formatted => {
|
|
data.formatted = true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use std::env::temp_dir;
|
|
use std::fs;
|
|
use std::io;
|
|
use std::io::Write;
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::atomic::AtomicU64;
|
|
use std::time::SystemTime;
|
|
|
|
use anyhow::Result;
|
|
use filetime::{set_file_mtime, FileTime};
|
|
use itertools::Itertools;
|
|
use test_case::test_case;
|
|
|
|
use ruff_cache::CACHE_DIR_NAME;
|
|
use ruff_linter::settings::flags;
|
|
use ruff_linter::settings::types::UnsafeFixes;
|
|
use ruff_python_ast::PySourceType;
|
|
use ruff_workspace::Settings;
|
|
|
|
use crate::cache::{self, FileCache, FileCacheData, FileCacheKey};
|
|
use crate::cache::{Cache, RelativePathBuf};
|
|
use crate::commands::format::{format_path, FormatCommandError, FormatMode, FormatResult};
|
|
use crate::diagnostics::{lint_path, Diagnostics};
|
|
|
|
#[test_case("../ruff_linter/resources/test/fixtures", "ruff_tests/cache_same_results_ruff_linter"; "ruff_linter_fixtures")]
|
|
#[test_case("../ruff_notebook/resources/test/fixtures", "ruff_tests/cache_same_results_ruff_notebook"; "ruff_notebook_fixtures")]
|
|
fn same_results(package_root: &str, cache_dir_path: &str) {
|
|
let mut cache_dir = temp_dir();
|
|
cache_dir.push(cache_dir_path);
|
|
let _ = fs::remove_dir_all(&cache_dir);
|
|
cache::init(&cache_dir).unwrap();
|
|
|
|
let settings = Settings {
|
|
cache_dir,
|
|
..Settings::default()
|
|
};
|
|
|
|
let package_root = fs::canonicalize(package_root).unwrap();
|
|
let cache = Cache::open(package_root.clone(), &settings);
|
|
assert_eq!(cache.changes.lock().unwrap().len(), 0);
|
|
|
|
let mut paths = Vec::new();
|
|
let mut parse_errors = Vec::new();
|
|
let mut expected_diagnostics = Diagnostics::default();
|
|
for entry in fs::read_dir(&package_root).unwrap() {
|
|
let entry = entry.unwrap();
|
|
if !entry.file_type().unwrap().is_dir() {
|
|
continue;
|
|
}
|
|
|
|
let dir_path = entry.path();
|
|
if dir_path.ends_with(CACHE_DIR_NAME) {
|
|
continue;
|
|
}
|
|
|
|
for entry in fs::read_dir(dir_path).unwrap() {
|
|
let entry = entry.unwrap();
|
|
if !entry.file_type().unwrap().is_file() {
|
|
continue;
|
|
}
|
|
|
|
let path = entry.path();
|
|
if path.ends_with("pyproject.toml") || path.ends_with("R.ipynb") {
|
|
continue;
|
|
}
|
|
|
|
let diagnostics = lint_path(
|
|
&path,
|
|
Some(&package_root),
|
|
&settings.linter,
|
|
Some(&cache),
|
|
flags::Noqa::Enabled,
|
|
flags::FixMode::Generate,
|
|
UnsafeFixes::Enabled,
|
|
)
|
|
.unwrap();
|
|
if diagnostics
|
|
.messages
|
|
.iter()
|
|
.any(|m| m.kind.name == "SyntaxError")
|
|
{
|
|
parse_errors.push(path.clone());
|
|
}
|
|
paths.push(path);
|
|
expected_diagnostics += diagnostics;
|
|
}
|
|
}
|
|
assert_ne!(paths, &[] as &[std::path::PathBuf], "no files checked");
|
|
|
|
cache.persist().unwrap();
|
|
|
|
let cache = Cache::open(package_root.clone(), &settings);
|
|
assert_ne!(cache.package.files.len(), 0);
|
|
|
|
parse_errors.sort();
|
|
|
|
for path in &paths {
|
|
if parse_errors.binary_search(path).is_ok() {
|
|
continue; // We don't cache parsing errors.
|
|
}
|
|
|
|
let relative_path = cache.relative_path(path).unwrap();
|
|
|
|
assert!(
|
|
cache.package.files.contains_key(relative_path),
|
|
"missing file from cache: '{}'",
|
|
relative_path.display()
|
|
);
|
|
}
|
|
|
|
let mut got_diagnostics = Diagnostics::default();
|
|
for path in paths {
|
|
got_diagnostics += lint_path(
|
|
&path,
|
|
Some(&package_root),
|
|
&settings.linter,
|
|
Some(&cache),
|
|
flags::Noqa::Enabled,
|
|
flags::FixMode::Generate,
|
|
UnsafeFixes::Enabled,
|
|
)
|
|
.unwrap();
|
|
}
|
|
|
|
assert_eq!(expected_diagnostics, got_diagnostics);
|
|
}
|
|
|
|
#[test]
|
|
fn cache_adds_file_on_lint() {
|
|
let source: &[u8] = b"a = 1\n\n__all__ = list([\"a\", \"b\"])\n";
|
|
|
|
let test_cache = TestCache::new("cache_adds_file_on_lint");
|
|
let cache = test_cache.open();
|
|
test_cache.write_source_file("source.py", source);
|
|
assert_eq!(cache.changes.lock().unwrap().len(), 0);
|
|
|
|
cache.persist().unwrap();
|
|
let cache = test_cache.open();
|
|
|
|
test_cache
|
|
.lint_file_with_cache("source.py", &cache)
|
|
.expect("Failed to lint test file");
|
|
assert_eq!(
|
|
cache.changes.lock().unwrap().len(),
|
|
1,
|
|
"A single new file should be added to the cache"
|
|
);
|
|
|
|
cache.persist().unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn cache_adds_files_on_lint() {
|
|
let source: &[u8] = b"a = 1\n\n__all__ = list([\"a\", \"b\"])\n";
|
|
|
|
let test_cache = TestCache::new("cache_adds_files_on_lint");
|
|
let cache = test_cache.open();
|
|
test_cache.write_source_file("source_1.py", source);
|
|
test_cache.write_source_file("source_2.py", source);
|
|
assert_eq!(cache.changes.lock().unwrap().len(), 0);
|
|
|
|
cache.persist().unwrap();
|
|
let cache = test_cache.open();
|
|
|
|
test_cache
|
|
.lint_file_with_cache("source_1.py", &cache)
|
|
.expect("Failed to lint test file");
|
|
test_cache
|
|
.lint_file_with_cache("source_2.py", &cache)
|
|
.expect("Failed to lint test file");
|
|
assert_eq!(
|
|
cache.changes.lock().unwrap().len(),
|
|
2,
|
|
"Both files should be added to the cache"
|
|
);
|
|
cache.persist().unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn cache_adds_files_on_format() {
|
|
let source: &[u8] = b"a = 1\n\n__all__ = list([\"a\", \"b\"])\n";
|
|
|
|
let test_cache = TestCache::new("cache_adds_files_on_format");
|
|
let cache = test_cache.open();
|
|
test_cache.write_source_file("source_1.py", source);
|
|
test_cache.write_source_file("source_2.py", source);
|
|
assert_eq!(cache.changes.lock().unwrap().len(), 0);
|
|
|
|
cache.persist().unwrap();
|
|
let cache = test_cache.open();
|
|
|
|
test_cache
|
|
.format_file_with_cache("source_1.py", &cache)
|
|
.expect("Failed to format test file");
|
|
test_cache
|
|
.format_file_with_cache("source_2.py", &cache)
|
|
.expect("Failed to format test file");
|
|
assert_eq!(
|
|
cache.changes.lock().unwrap().len(),
|
|
2,
|
|
"Both files should be added to the cache"
|
|
);
|
|
|
|
cache.persist().unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn cache_invalidated_on_file_modified_time() {
|
|
let source: &[u8] = b"a = 1\n\n__all__ = list([\"a\", \"b\"])\n";
|
|
|
|
let test_cache = TestCache::new("cache_invalidated_on_file_modified_time");
|
|
let cache = test_cache.open();
|
|
let source_path = test_cache.write_source_file("source.py", source);
|
|
assert_eq!(cache.changes.lock().unwrap().len(), 0);
|
|
|
|
let expected_diagnostics = test_cache
|
|
.lint_file_with_cache("source.py", &cache)
|
|
.expect("Failed to lint test file");
|
|
|
|
cache.persist().unwrap();
|
|
let cache = test_cache.open();
|
|
|
|
// Update the modified time of the file to a time in the future
|
|
set_file_mtime(
|
|
source_path,
|
|
FileTime::from_system_time(SystemTime::now() + std::time::Duration::from_secs(1)),
|
|
)
|
|
.unwrap();
|
|
|
|
let got_diagnostics = test_cache
|
|
.lint_file_with_cache("source.py", &cache)
|
|
.expect("Failed to lint test file");
|
|
|
|
assert_eq!(
|
|
cache.changes.lock().unwrap().len(),
|
|
1,
|
|
"Cache should not be used, the file should be treated as new and added to the cache"
|
|
);
|
|
|
|
assert_eq!(
|
|
expected_diagnostics, got_diagnostics,
|
|
"The diagnostics should not change"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn cache_invalidated_on_permission_change() {
|
|
// Regression test for issue #3086.
|
|
|
|
#[cfg(unix)]
|
|
#[allow(clippy::items_after_statements)]
|
|
fn flip_execute_permission_bit(path: &Path) -> io::Result<()> {
|
|
use std::os::unix::fs::PermissionsExt;
|
|
let file = fs::OpenOptions::new().write(true).open(path)?;
|
|
let perms = file.metadata()?.permissions();
|
|
file.set_permissions(PermissionsExt::from_mode(perms.mode() ^ 0o111))
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
#[allow(clippy::items_after_statements)]
|
|
fn flip_read_only_permission(path: &Path) -> io::Result<()> {
|
|
let file = fs::OpenOptions::new().write(true).open(path)?;
|
|
let mut perms = file.metadata()?.permissions();
|
|
perms.set_readonly(!perms.readonly());
|
|
file.set_permissions(perms)
|
|
}
|
|
|
|
let source: &[u8] = b"a = 1\n\n__all__ = list([\"a\", \"b\"])\n";
|
|
|
|
let test_cache = TestCache::new("cache_invalidated_on_permission_change");
|
|
let cache = test_cache.open();
|
|
let path = test_cache.write_source_file("source.py", source);
|
|
assert_eq!(cache.changes.lock().unwrap().len(), 0);
|
|
|
|
let expected_diagnostics = test_cache
|
|
.lint_file_with_cache("source.py", &cache)
|
|
.unwrap();
|
|
|
|
cache.persist().unwrap();
|
|
let cache = test_cache.open();
|
|
|
|
// Flip the permissions on the file
|
|
#[cfg(unix)]
|
|
flip_execute_permission_bit(&path).unwrap();
|
|
#[cfg(windows)]
|
|
flip_read_only_permission(&path).unwrap();
|
|
|
|
let got_diagnostics = test_cache
|
|
.lint_file_with_cache("source.py", &cache)
|
|
.unwrap();
|
|
|
|
assert_eq!(
|
|
cache.changes.lock().unwrap().len(),
|
|
1,
|
|
"Cache should not be used, the file should be treated as new and added to the cache"
|
|
);
|
|
|
|
assert_eq!(
|
|
expected_diagnostics, got_diagnostics,
|
|
"The diagnostics should not change"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn cache_removes_stale_files_on_persist() {
|
|
let test_cache = TestCache::new("cache_removes_stale_files_on_persist");
|
|
let mut cache = test_cache.open();
|
|
|
|
// Add a file to the cache that hasn't been linted or seen since the '70s!
|
|
let old_path_key = RelativePathBuf::from("old.py");
|
|
cache.package.files.insert(
|
|
old_path_key,
|
|
FileCache {
|
|
key: 123,
|
|
last_seen: AtomicU64::new(123),
|
|
data: FileCacheData::default(),
|
|
},
|
|
);
|
|
|
|
// Now actually lint a file.
|
|
let source: &[u8] = b"a = 1\n\n__all__ = list([\"a\", \"b\"])\n";
|
|
test_cache.write_source_file("new.py", source);
|
|
let new_path_key = RelativePathBuf::from("new.py");
|
|
assert_eq!(cache.changes.lock().unwrap().len(), 0);
|
|
|
|
test_cache
|
|
.lint_file_with_cache("new.py", &cache)
|
|
.expect("Failed to lint test file");
|
|
|
|
// Storing the cache should remove the old (`old.py`) file.
|
|
cache.persist().unwrap();
|
|
// So we when we open the cache again it shouldn't contain `old.py`.
|
|
let cache = test_cache.open();
|
|
|
|
assert_eq!(
|
|
cache.package.files.keys().collect_vec(),
|
|
vec![&new_path_key],
|
|
"Only the new file should be present"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn format_updates_cache_entry() {
|
|
let source: &[u8] = b"a = 1\n\n__all__ = list([\"a\", \"b\"])\n";
|
|
|
|
let test_cache = TestCache::new("format_updates_cache_entry");
|
|
let cache = test_cache.open();
|
|
test_cache.write_source_file("source.py", source);
|
|
assert_eq!(cache.changes.lock().unwrap().len(), 0);
|
|
|
|
cache.persist().unwrap();
|
|
let cache = test_cache.open();
|
|
|
|
// Cache the lint results
|
|
test_cache
|
|
.lint_file_with_cache("source.py", &cache)
|
|
.expect("Failed to lint test file");
|
|
cache.persist().unwrap();
|
|
|
|
let mut cache = test_cache.open();
|
|
|
|
// Now lint the file
|
|
test_cache
|
|
.format_file_with_cache("source.py", &cache)
|
|
.expect("Failed to format test file");
|
|
|
|
cache.save();
|
|
|
|
assert_eq!(cache.package.files.len(), 1);
|
|
|
|
let Some(file_cache) = cache.get(
|
|
Path::new("source.py"),
|
|
&FileCacheKey::from_path(&test_cache.package_root.join("source.py")).unwrap(),
|
|
) else {
|
|
panic!("Cache entry for `source.py` is missing.");
|
|
};
|
|
|
|
assert!(file_cache.data.lint.is_some());
|
|
assert!(file_cache.data.formatted);
|
|
}
|
|
|
|
#[test]
|
|
fn file_changes_invalidate_file_cache() {
|
|
let source: &[u8] = b"a = 1\n\n__all__ = list([\"a\", \"b\"])\n";
|
|
|
|
let test_cache = TestCache::new("file_changes_invalidate_file_cache");
|
|
let cache = test_cache.open();
|
|
let source_path = test_cache.write_source_file("source.py", source);
|
|
assert_eq!(cache.changes.lock().unwrap().len(), 0);
|
|
|
|
cache.persist().unwrap();
|
|
let cache = test_cache.open();
|
|
|
|
// Cache the format and lint results
|
|
test_cache
|
|
.lint_file_with_cache("source.py", &cache)
|
|
.expect("Failed to lint test file");
|
|
test_cache
|
|
.format_file_with_cache("source.py", &cache)
|
|
.expect("Failed to format test file");
|
|
|
|
cache.persist().unwrap();
|
|
|
|
let mut cache = test_cache.open();
|
|
assert_eq!(cache.package.files.len(), 1);
|
|
|
|
set_file_mtime(
|
|
&source_path,
|
|
FileTime::from_system_time(SystemTime::now() + std::time::Duration::from_secs(1)),
|
|
)
|
|
.unwrap();
|
|
|
|
test_cache
|
|
.format_file_with_cache("source.py", &cache)
|
|
.expect("Failed to format test file");
|
|
|
|
cache.save();
|
|
|
|
assert_eq!(cache.package.files.len(), 1);
|
|
|
|
let Some(file_cache) = cache.get(
|
|
Path::new("source.py"),
|
|
&FileCacheKey::from_path(&source_path).unwrap(),
|
|
) else {
|
|
panic!("Cache entry for `source.py` is missing.");
|
|
};
|
|
|
|
assert_eq!(file_cache.data.lint, None);
|
|
assert!(file_cache.data.formatted);
|
|
}
|
|
|
|
struct TestCache {
|
|
package_root: PathBuf,
|
|
settings: Settings,
|
|
}
|
|
|
|
impl TestCache {
|
|
fn new(test_case: &str) -> Self {
|
|
// Build a new cache directory and clear it
|
|
let mut test_dir = temp_dir();
|
|
test_dir.push("ruff_tests/cache");
|
|
test_dir.push(test_case);
|
|
|
|
let _ = fs::remove_dir_all(&test_dir);
|
|
|
|
// Create separate directories for the cache and the test package
|
|
let cache_dir = test_dir.join("cache");
|
|
let package_root = test_dir.join("package");
|
|
|
|
cache::init(&cache_dir).unwrap();
|
|
fs::create_dir(package_root.clone()).unwrap();
|
|
|
|
let settings = Settings {
|
|
cache_dir,
|
|
..Settings::default()
|
|
};
|
|
|
|
Self {
|
|
package_root,
|
|
settings,
|
|
}
|
|
}
|
|
|
|
fn write_source_file(&self, path: &str, contents: &[u8]) -> PathBuf {
|
|
let path = self.package_root.join(path);
|
|
let mut file = fs::OpenOptions::new()
|
|
.write(true)
|
|
.create(true)
|
|
.truncate(true)
|
|
.open(&*path)
|
|
.unwrap();
|
|
file.write_all(contents).unwrap();
|
|
file.sync_data().unwrap();
|
|
path
|
|
}
|
|
|
|
fn open(&self) -> Cache {
|
|
Cache::open(self.package_root.clone(), &self.settings)
|
|
}
|
|
|
|
fn lint_file_with_cache(
|
|
&self,
|
|
path: &str,
|
|
cache: &Cache,
|
|
) -> Result<Diagnostics, anyhow::Error> {
|
|
lint_path(
|
|
&self.package_root.join(path),
|
|
Some(&self.package_root),
|
|
&self.settings.linter,
|
|
Some(cache),
|
|
flags::Noqa::Enabled,
|
|
flags::FixMode::Generate,
|
|
UnsafeFixes::Enabled,
|
|
)
|
|
}
|
|
|
|
fn format_file_with_cache(
|
|
&self,
|
|
path: &str,
|
|
cache: &Cache,
|
|
) -> Result<FormatResult, FormatCommandError> {
|
|
let file_path = self.package_root.join(path);
|
|
format_path(
|
|
&file_path,
|
|
&self.settings.formatter,
|
|
PySourceType::Python,
|
|
FormatMode::Write,
|
|
Some(cache),
|
|
)
|
|
}
|
|
}
|
|
|
|
impl Drop for TestCache {
|
|
fn drop(&mut self) {
|
|
let _ = fs::remove_dir_all(&self.settings.cache_dir);
|
|
}
|
|
}
|
|
}
|