[ty] Allow overriding rules for specific files (#18648)
Some checks are pending
CI / Determine changes (push) Waiting to run
CI / cargo fmt (push) Waiting to run
CI / cargo clippy (push) Blocked by required conditions
CI / cargo test (linux) (push) Blocked by required conditions
CI / cargo test (linux, release) (push) Blocked by required conditions
CI / cargo test (windows) (push) Blocked by required conditions
CI / cargo test (wasm) (push) Blocked by required conditions
CI / cargo build (release) (push) Waiting to run
CI / cargo build (msrv) (push) Blocked by required conditions
CI / cargo fuzz build (push) Blocked by required conditions
CI / fuzz parser (push) Blocked by required conditions
CI / test scripts (push) Blocked by required conditions
CI / ecosystem (push) Blocked by required conditions
CI / Fuzz for new ty panics (push) Blocked by required conditions
CI / cargo shear (push) Blocked by required conditions
CI / python package (push) Waiting to run
CI / pre-commit (push) Waiting to run
CI / mkdocs (push) Waiting to run
CI / formatter instabilities and black similarity (push) Blocked by required conditions
CI / test ruff-lsp (push) Blocked by required conditions
CI / check playground (push) Blocked by required conditions
CI / benchmarks (push) Blocked by required conditions
[ty Playground] Release / publish (push) Waiting to run

This commit is contained in:
Micha Reiser 2025-06-15 15:27:39 +02:00 committed by GitHub
parent 782363b736
commit 3a430fa6da
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
31 changed files with 1945 additions and 312 deletions

View file

@ -30,6 +30,7 @@ crossbeam = { workspace = true }
globset = { workspace = true }
notify = { workspace = true }
pep440_rs = { workspace = true, features = ["version-ranges"] }
ordermap = { workspace = true, features = ["serde"] }
rayon = { workspace = true }
regex = { workspace = true }
regex-automata = { workspace = true }

View file

@ -1,5 +1,6 @@
use std::{collections::HashMap, hash::BuildHasher};
use ordermap::OrderMap;
use ruff_db::system::SystemPathBuf;
use ruff_python_ast::PythonVersion;
use ty_python_semantic::{PythonPath, PythonPlatform};
@ -111,6 +112,18 @@ where
}
}
impl<K, V, S> Combine for OrderMap<K, V, S>
where
K: Eq + std::hash::Hash,
S: BuildHasher,
{
fn combine_with(&mut self, other: Self) {
for (k, v) in other {
self.entry(k).or_insert(v);
}
}
}
/// Implements [`Combine`] for a value that always returns `self` when combined with another value.
macro_rules! impl_noop_combine {
($name:ident) => {
@ -150,6 +163,7 @@ impl_noop_combine!(String);
#[cfg(test)]
mod tests {
use crate::combine::Combine;
use ordermap::OrderMap;
use std::collections::HashMap;
#[test]
@ -188,4 +202,24 @@ mod tests {
]))
);
}
#[test]
fn combine_order_map() {
let a: OrderMap<u32, _> = OrderMap::from_iter([(1, "a"), (2, "a"), (3, "a")]);
let b: OrderMap<u32, _> = OrderMap::from_iter([(0, "b"), (2, "b"), (5, "b")]);
assert_eq!(None.combine(Some(b.clone())), Some(b.clone()));
assert_eq!(Some(a.clone()).combine(None), Some(a.clone()));
assert_eq!(
Some(a).combine(Some(b)),
// The value from `a` takes precedence
Some(OrderMap::from_iter([
(1, "a"),
(2, "a"),
(3, "a"),
(0, "b"),
(5, "b")
]))
);
}
}

View file

@ -1,6 +1,7 @@
use std::panic::{AssertUnwindSafe, RefUnwindSafe};
use std::sync::Arc;
use crate::metadata::settings::file_settings;
use crate::{DEFAULT_LINT_REGISTRY, DummyReporter};
use crate::{Project, ProjectMetadata, Reporter};
use ruff_db::diagnostic::Diagnostic;
@ -162,8 +163,9 @@ impl SemanticDb for ProjectDatabase {
project.is_file_open(self, file)
}
fn rule_selection(&self) -> &RuleSelection {
self.project().rules(self)
fn rule_selection(&self, file: File) -> &RuleSelection {
let settings = file_settings(self, file);
settings.rules(self)
}
fn lint_registry(&self) -> &LintRegistry {
@ -340,7 +342,7 @@ pub(crate) mod tests {
!file.path(self).is_vendored_path()
}
fn rule_selection(&self) -> &RuleSelection {
fn rule_selection(&self, _file: ruff_db::files::File) -> &RuleSelection {
self.project().rules(self)
}

View file

@ -58,6 +58,12 @@ impl IncludeExcludeFilter {
}
}
impl std::fmt::Display for IncludeExcludeFilter {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "include={}, exclude={}", &self.include, &self.exclude)
}
}
#[derive(Copy, Clone, Eq, PartialEq)]
pub(crate) enum GlobFilterCheckMode {
/// The paths are checked top-to-bottom and inclusion is determined

View file

@ -6,6 +6,7 @@
//! * `/src/**` excludes all files and directories inside a directory named `src` but not `src` itself.
//! * `!src` allows a file or directory named `src` anywhere in the path
use std::fmt::Formatter;
use std::sync::Arc;
use globset::{Candidate, GlobBuilder, GlobSet, GlobSetBuilder};
@ -63,6 +64,12 @@ impl ExcludeFilter {
}
}
impl std::fmt::Display for ExcludeFilter {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
f.debug_list().entries(&self.ignore.globs).finish()
}
}
pub(crate) struct ExcludeFilterBuilder {
ignore: GitignoreBuilder,
}
@ -150,8 +157,8 @@ impl Gitignore {
impl std::fmt::Debug for Gitignore {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Gitignore")
.field("globs", &self.globs)
f.debug_tuple("Gitignore")
.field(&self.globs)
.finish_non_exhaustive()
}
}
@ -230,13 +237,18 @@ impl GitignoreBuilder {
/// Adds a gitignore like glob pattern to this builder.
///
/// If the pattern could not be parsed as a glob, then an error is returned.
fn add(&mut self, mut pattern: &str) -> Result<&mut GitignoreBuilder, globset::Error> {
fn add(
&mut self,
pattern: &AbsolutePortableGlobPattern,
) -> Result<&mut GitignoreBuilder, globset::Error> {
let mut glob = IgnoreGlob {
original: pattern.to_string(),
original: pattern.relative().to_string(),
is_allow: false,
is_only_dir: false,
};
let mut pattern = pattern.absolute();
// File names starting with `!` are escaped with a backslash. Strip the backslash.
// This is not a negated pattern!
if pattern.starts_with("\\!") {

View file

@ -2,6 +2,7 @@ use globset::{Glob, GlobBuilder, GlobSet, GlobSetBuilder};
use regex_automata::dfa;
use regex_automata::dfa::Automaton;
use ruff_db::system::SystemPath;
use std::fmt::Formatter;
use std::path::{MAIN_SEPARATOR, MAIN_SEPARATOR_STR};
use tracing::warn;
@ -92,12 +93,18 @@ impl IncludeFilter {
impl std::fmt::Debug for IncludeFilter {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("IncludeFilder")
.field("original_patterns", &self.original_patterns)
f.debug_tuple("IncludeFilter")
.field(&self.original_patterns)
.finish_non_exhaustive()
}
}
impl std::fmt::Display for IncludeFilter {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
f.debug_list().entries(&self.original_patterns).finish()
}
}
impl PartialEq for IncludeFilter {
fn eq(&self, other: &Self) -> bool {
self.original_patterns == other.original_patterns
@ -127,35 +134,35 @@ impl IncludeFilterBuilder {
&mut self,
input: &AbsolutePortableGlobPattern,
) -> Result<&mut Self, globset::Error> {
let mut glob = &**input;
let mut glob_pattern = input.absolute();
let mut only_directory = false;
// A pattern ending with a `/` should only match directories. E.g. `src/` only matches directories
// whereas `src` matches both files and directories.
// We need to remove the `/` to ensure that a path missing the trailing `/` matches.
if let Some(after) = input.strip_suffix('/') {
if let Some(after) = glob_pattern.strip_suffix('/') {
// Escaped `/` or `\` aren't allowed. `portable_glob::parse` will error
only_directory = true;
glob = after;
glob_pattern = after;
}
// If regex ends with `/**`, only push that one glob and regex
// Otherwise, push two regex, one for `/**` and one for without
let glob = GlobBuilder::new(glob)
let glob = GlobBuilder::new(glob_pattern)
.literal_separator(true)
// No need to support Windows-style paths, so the backslash can be used a escape.
.backslash_escape(true)
.build()?;
self.original_pattern.push(input.to_string());
self.original_pattern.push(input.relative().to_string());
// `lib` is the same as `lib/**`
// Add a glob that matches `lib` exactly, change the glob to `lib/**`.
if input.ends_with("**") {
if glob_pattern.ends_with("**") {
self.push_prefix_regex(&glob);
self.set.add(glob);
} else {
let prefix_glob = GlobBuilder::new(&format!("{glob}/**"))
let prefix_glob = GlobBuilder::new(&format!("{glob_pattern}/**"))
.literal_separator(true)
// No need to support Windows-style paths, so the backslash can be used a escape.
.backslash_escape(true)

View file

@ -8,6 +8,7 @@
//! [Source](https://github.com/astral-sh/uv/blob/main/crates/uv-globfilter/src/portable_glob.rs)
use ruff_db::system::SystemPath;
use std::error::Error as _;
use std::ops::Deref;
use std::{fmt::Write, path::MAIN_SEPARATOR};
use thiserror::Error;
@ -65,14 +66,12 @@ impl<'a> PortableGlobPattern<'a> {
}
if star_run >= 3 {
return Err(PortableGlobError::TooManyStars {
glob: glob.to_string(),
// We don't update pos for the stars.
pos,
});
} else if star_run == 2 {
if chars.peek().is_some_and(|(_, c)| *c != '/') {
return Err(PortableGlobError::TooManyStars {
glob: glob.to_string(),
// We don't update pos for the stars.
pos,
});
@ -83,10 +82,7 @@ impl<'a> PortableGlobPattern<'a> {
start_or_slash = false;
} else if c == '.' {
if start_or_slash && matches!(chars.peek(), Some((_, '.'))) {
return Err(PortableGlobError::ParentDirectory {
pos,
glob: glob.to_string(),
});
return Err(PortableGlobError::ParentDirectory { pos });
}
start_or_slash = false;
} else if c == '/' {
@ -99,7 +95,6 @@ impl<'a> PortableGlobPattern<'a> {
break;
} else {
return Err(PortableGlobError::InvalidCharacterRange {
glob: glob.to_string(),
pos,
invalid: InvalidChar(c),
});
@ -111,24 +106,17 @@ impl<'a> PortableGlobPattern<'a> {
Some((pos, '/' | '\\')) => {
// For cross-platform compatibility, we don't allow forward slashes or
// backslashes to be escaped.
return Err(PortableGlobError::InvalidEscapee {
glob: glob.to_string(),
pos,
});
return Err(PortableGlobError::InvalidEscapee { pos });
}
Some(_) => {
// Escaped character
}
None => {
return Err(PortableGlobError::TrailingEscape {
glob: glob.to_string(),
pos,
});
return Err(PortableGlobError::TrailingEscape { pos });
}
}
} else {
return Err(PortableGlobError::InvalidCharacter {
glob: glob.to_string(),
pos,
invalid: InvalidChar(c),
});
@ -160,12 +148,18 @@ impl<'a> PortableGlobPattern<'a> {
// Patterns that don't contain any `/`, e.g. `.venv` are unanchored patterns
// that match anywhere.
if !self.chars().any(|c| c == '/') {
return AbsolutePortableGlobPattern(self.to_string());
return AbsolutePortableGlobPattern {
absolute: self.to_string(),
relative: self.pattern.to_string(),
};
}
}
if pattern.starts_with('/') {
return AbsolutePortableGlobPattern(pattern.to_string());
return AbsolutePortableGlobPattern {
absolute: pattern.to_string(),
relative: self.pattern.to_string(),
};
}
let mut rest = pattern;
@ -206,9 +200,15 @@ impl<'a> PortableGlobPattern<'a> {
output.push_str(rest);
if negated {
// If the pattern is negated, we need to keep the leading `!`.
AbsolutePortableGlobPattern(format!("!{output}"))
AbsolutePortableGlobPattern {
absolute: format!("!{output}"),
relative: self.pattern.to_string(),
}
} else {
AbsolutePortableGlobPattern(output)
AbsolutePortableGlobPattern {
absolute: output,
relative: self.pattern.to_string(),
}
}
}
}
@ -225,53 +225,48 @@ impl Deref for PortableGlobPattern<'_> {
///
/// E.g., `./src/**` becomes `/root/src/**` when anchored to `/root`.
#[derive(Debug, Eq, PartialEq, Hash)]
pub(crate) struct AbsolutePortableGlobPattern(String);
pub(crate) struct AbsolutePortableGlobPattern {
absolute: String,
relative: String,
}
impl Deref for AbsolutePortableGlobPattern {
type Target = str;
impl AbsolutePortableGlobPattern {
/// Returns the absolute path of this glob pattern.
pub(crate) fn absolute(&self) -> &str {
&self.absolute
}
fn deref(&self) -> &Self::Target {
&self.0
/// Returns the relative path of this glob pattern.
pub(crate) fn relative(&self) -> &str {
&self.relative
}
}
#[derive(Debug, Error)]
pub(crate) enum PortableGlobError {
/// Shows the failing glob in the error message.
#[error(transparent)]
#[error("{desc}", desc=.0.description())]
GlobError(#[from] globset::Error),
#[error(
"The parent directory operator (`..`) at position {pos} is not allowed in glob: `{glob}`"
)]
ParentDirectory { glob: String, pos: usize },
#[error("The parent directory operator (`..`) at position {pos} is not allowed")]
ParentDirectory { pos: usize },
#[error(
"Invalid character `{invalid}` at position {pos} in glob: `{glob}`. hint: Characters can be escaped with a backslash"
"Invalid character `{invalid}` at position {pos}. hint: Characters can be escaped with a backslash"
)]
InvalidCharacter {
glob: String,
pos: usize,
invalid: InvalidChar,
},
InvalidCharacter { pos: usize, invalid: InvalidChar },
#[error(
"Path separators can't be escaped, invalid character at position {pos} in glob: `{glob}`"
)]
InvalidEscapee { glob: String, pos: usize },
#[error("Path separators can't be escaped, invalid character at position {pos}")]
InvalidEscapee { pos: usize },
#[error("Invalid character `{invalid}` in range at position {pos} in glob: `{glob}`")]
InvalidCharacterRange {
glob: String,
pos: usize,
invalid: InvalidChar,
},
#[error("Invalid character `{invalid}` in range at position {pos}")]
InvalidCharacterRange { pos: usize, invalid: InvalidChar },
#[error("Too many stars at position {pos} in glob: `{glob}`")]
TooManyStars { glob: String, pos: usize },
#[error("Too many stars at position {pos}")]
TooManyStars { pos: usize },
#[error("Trailing backslash at position {pos} in glob: `{glob}`")]
TrailingEscape { glob: String, pos: usize },
#[error("Trailing backslash at position {pos}")]
TrailingEscape { pos: usize },
}
#[derive(Copy, Clone, Debug)]
@ -303,57 +298,57 @@ mod tests {
assert_snapshot!(
parse_err(".."),
@"The parent directory operator (`..`) at position 1 is not allowed in glob: `..`"
@"The parent directory operator (`..`) at position 1 is not allowed"
);
assert_snapshot!(
parse_err("licenses/.."),
@"The parent directory operator (`..`) at position 10 is not allowed in glob: `licenses/..`"
@"The parent directory operator (`..`) at position 10 is not allowed"
);
assert_snapshot!(
parse_err("licenses/LICEN!E.txt"),
@"Invalid character `!` at position 15 in glob: `licenses/LICEN!E.txt`. hint: Characters can be escaped with a backslash"
@"Invalid character `!` at position 15. hint: Characters can be escaped with a backslash"
);
assert_snapshot!(
parse_err("licenses/LICEN[!C]E.txt"),
@"Invalid character `!` in range at position 15 in glob: `licenses/LICEN[!C]E.txt`"
@"Invalid character `!` in range at position 15"
);
assert_snapshot!(
parse_err("licenses/LICEN[C?]E.txt"),
@"Invalid character `?` in range at position 16 in glob: `licenses/LICEN[C?]E.txt`"
@"Invalid character `?` in range at position 16"
);
assert_snapshot!(
parse_err("******"),
@"Too many stars at position 1 in glob: `******`"
@"Too many stars at position 1"
);
assert_snapshot!(
parse_err("licenses/**license"),
@"Too many stars at position 10 in glob: `licenses/**license`"
@"Too many stars at position 10"
);
assert_snapshot!(
parse_err("licenses/***/licenses.csv"),
@"Too many stars at position 10 in glob: `licenses/***/licenses.csv`"
@"Too many stars at position 10"
);
assert_snapshot!(
parse_err(r"**/@test"),
@"Invalid character `@` at position 4 in glob: `**/@test`. hint: Characters can be escaped with a backslash"
@"Invalid character `@` at position 4. hint: Characters can be escaped with a backslash"
);
// Escapes are not allowed in strict PEP 639 mode
assert_snapshot!(
parse_err(r"public domain/Gulliver\\s Travels.txt"),
@r"Invalid character ` ` at position 7 in glob: `public domain/Gulliver\\s Travels.txt`. hint: Characters can be escaped with a backslash"
@r"Invalid character ` ` at position 7. hint: Characters can be escaped with a backslash"
);
assert_snapshot!(
parse_err(r"**/@test"),
@"Invalid character `@` at position 4 in glob: `**/@test`. hint: Characters can be escaped with a backslash"
@"Invalid character `@` at position 4. hint: Characters can be escaped with a backslash"
);
// Escaping slashes is not allowed.
assert_snapshot!(
parse_err(r"licenses\\MIT.txt"),
@r"Path separators can't be escaped, invalid character at position 9 in glob: `licenses\\MIT.txt`"
@r"Path separators can't be escaped, invalid character at position 9"
);
assert_snapshot!(
parse_err(r"licenses\/MIT.txt"),
@r"Path separators can't be escaped, invalid character at position 9 in glob: `licenses\/MIT.txt`"
@r"Path separators can't be escaped, invalid character at position 9"
);
}
@ -388,8 +383,8 @@ mod tests {
#[track_caller]
fn assert_absolute_path(pattern: &str, relative_to: impl AsRef<SystemPath>, expected: &str) {
let pattern = PortableGlobPattern::parse(pattern, true).unwrap();
let absolute = pattern.into_absolute(relative_to);
assert_eq!(&*absolute, expected);
let pattern = pattern.into_absolute(relative_to);
assert_eq!(pattern.absolute(), expected);
}
#[test]

File diff suppressed because it is too large Load diff

View file

@ -1,9 +1,10 @@
use std::sync::Arc;
use ruff_db::diagnostic::DiagnosticFormat;
use ruff_db::{diagnostic::DiagnosticFormat, files::File};
use ty_python_semantic::lint::RuleSelection;
use crate::glob::IncludeExcludeFilter;
use crate::metadata::options::InnerOverrideOptions;
use crate::{Db, combine::Combine, glob::IncludeExcludeFilter};
/// The resolved [`super::Options`] for the project.
///
@ -23,6 +24,13 @@ pub struct Settings {
pub(super) rules: Arc<RuleSelection>,
pub(super) terminal: TerminalSettings,
pub(super) src: SrcSettings,
/// Settings for configuration overrides that apply to specific file patterns.
///
/// Each override can specify include/exclude patterns and rule configurations
/// that apply to matching files. Multiple overrides can match the same file,
/// with later overrides taking precedence.
pub(super) overrides: Vec<Override>,
}
impl Settings {
@ -41,6 +49,10 @@ impl Settings {
pub fn terminal(&self) -> &TerminalSettings {
&self.terminal
}
pub fn overrides(&self) -> &[Override] {
&self.overrides
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
@ -54,3 +66,138 @@ pub struct SrcSettings {
pub respect_ignore_files: bool,
pub files: IncludeExcludeFilter,
}
/// A single configuration override that applies to files matching specific patterns.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Override {
/// File pattern filter to determine which files this override applies to.
pub(super) files: IncludeExcludeFilter,
/// The raw options as specified in the configuration (minus `include` and `exclude`.
/// Necessary to merge multiple overrides if necessary.
pub(super) options: Arc<InnerOverrideOptions>,
/// Pre-resolved rule selection for this override alone.
/// Used for efficient lookup when only this override matches a file.
pub(super) settings: Arc<OverrideSettings>,
}
impl Override {
/// Returns whether this override applies to the given file path.
pub fn matches_file(&self, path: &ruff_db::system::SystemPath) -> bool {
use crate::glob::{GlobFilterCheckMode, IncludeResult};
matches!(
self.files
.is_file_included(path, GlobFilterCheckMode::Adhoc),
IncludeResult::Included
)
}
}
/// Resolves the settings for a given file.
#[salsa::tracked(returns(ref))]
pub(crate) fn file_settings(db: &dyn Db, file: File) -> FileSettings {
let settings = db.project().settings(db);
let path = match file.path(db) {
ruff_db::files::FilePath::System(path) => path,
ruff_db::files::FilePath::SystemVirtual(_) | ruff_db::files::FilePath::Vendored(_) => {
return FileSettings::Global;
}
};
let mut matching_overrides = settings
.overrides()
.iter()
.filter(|over| over.matches_file(path));
let Some(first) = matching_overrides.next() else {
// If the file matches no override, it uses the global settings.
return FileSettings::Global;
};
let Some(second) = matching_overrides.next() else {
tracing::debug!("Applying override for file `{path}`: {}", first.files);
// If the file matches only one override, return that override's settings.
return FileSettings::File(Arc::clone(&first.settings));
};
let mut filters = tracing::enabled!(tracing::Level::DEBUG)
.then(|| format!("({}), ({})", first.files, second.files));
let mut overrides = vec![Arc::clone(&first.options), Arc::clone(&second.options)];
for over in matching_overrides {
use std::fmt::Write;
if let Some(filters) = &mut filters {
let _ = write!(filters, ", ({})", over.files);
}
overrides.push(Arc::clone(&over.options));
}
if let Some(filters) = &filters {
tracing::debug!("Applying multiple overrides for file `{path}`: {filters}");
}
merge_overrides(db, overrides, ())
}
/// Merges multiple override options, caching the result.
///
/// Overrides often apply to multiple files. This query ensures that we avoid
/// resolving the same override combinations multiple times.
///
/// ## What's up with the `()` argument?
///
/// This is to make Salsa happy because it requires that queries with only a single argument
/// take a salsa-struct as argument, which isn't the case here. The `()` enables salsa's
/// automatic interning for the arguments.
#[salsa::tracked]
fn merge_overrides(db: &dyn Db, overrides: Vec<Arc<InnerOverrideOptions>>, _: ()) -> FileSettings {
let mut overrides = overrides.into_iter().rev();
let mut merged = (*overrides.next().unwrap()).clone();
for option in overrides {
merged.combine_with((*option).clone());
}
merged
.rules
.combine_with(db.project().metadata(db).options().rules.clone());
let Some(rules) = merged.rules else {
return FileSettings::Global;
};
// It's okay to ignore the errors here because the rules are eagerly validated
// during `overrides.to_settings()`.
let rules = rules.to_rule_selection(db, &mut Vec::new());
FileSettings::File(Arc::new(OverrideSettings { rules }))
}
/// The resolved settings for a file.
#[derive(Debug, Eq, PartialEq, Clone)]
pub enum FileSettings {
/// The file uses the global settings.
Global,
/// The file has specific override settings.
File(Arc<OverrideSettings>),
}
impl FileSettings {
pub fn rules<'a>(&'a self, db: &'a dyn Db) -> &'a RuleSelection {
match self {
FileSettings::Global => db.project().settings(db).rules(),
FileSettings::File(override_settings) => &override_settings.rules,
}
}
}
#[derive(Debug, Eq, PartialEq, Clone)]
pub struct OverrideSettings {
pub(super) rules: RuleSelection,
}

View file

@ -407,6 +407,12 @@ impl RelativeIncludePattern {
}
}
impl std::fmt::Display for RelativeIncludePattern {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
self.0.fmt(f)
}
}
#[derive(
Debug,
Clone,
@ -456,3 +462,9 @@ impl RelativeExcludePattern {
Ok(pattern.into_absolute(relative_to))
}
}
impl std::fmt::Display for RelativeExcludePattern {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
self.0.fmt(f)
}
}