[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

2
Cargo.lock generated
View file

@ -1952,6 +1952,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d31b8b7a99f71bdff4235faf9ce9eada0ad3562c8fbeb7d607d9f41a6ec569d"
dependencies = [
"indexmap",
"serde",
]
[[package]]
@ -3949,6 +3950,7 @@ dependencies = [
"globset",
"insta",
"notify",
"ordermap",
"pep440_rs",
"rayon",
"regex",

View file

@ -668,6 +668,73 @@ pub enum DiagnosticId {
/// A glob pattern doesn't follow the expected syntax.
InvalidGlob,
/// An `include` glob without any patterns.
///
/// ## Why is this bad?
/// An `include` glob without any patterns won't match any files. This is probably a mistake and
/// either the `include` should be removed or a pattern should be added.
///
/// ## Example
/// ```toml
/// [src]
/// include = []
/// ```
///
/// Use instead:
///
/// ```toml
/// [src]
/// include = ["src"]
/// ```
///
/// or remove the `include` option.
EmptyInclude,
/// An override configuration is unnecessary because it applies to all files.
///
/// ## Why is this bad?
/// An overrides section that applies to all files is probably a mistake and can be rolled-up into the root configuration.
///
/// ## Example
/// ```toml
/// [[overrides]]
/// [overrides.rules]
/// unused-reference = "ignore"
/// ```
///
/// Use instead:
///
/// ```toml
/// [rules]
/// unused-reference = "ignore"
/// ```
///
/// or
///
/// ```toml
/// [[overrides]]
/// include = ["test"]
///
/// [overrides.rules]
/// unused-reference = "ignore"
/// ```
UnnecessaryOverridesSection,
/// An `overrides` section in the configuration that doesn't contain any overrides.
///
/// ## Why is this bad?
/// An `overrides` section without any configuration overrides is probably a mistake.
/// It is either a leftover after removing overrides, or a user forgot to add any overrides,
/// or used an incorrect syntax to do so (e.g. used `rules` instead of `overrides.rules`).
///
/// ## Example
/// ```toml
/// [[overrides]]
/// include = ["test"]
/// # no `[overrides.rules]`
/// ```
UselessOverridesSection,
}
impl DiagnosticId {
@ -703,6 +770,9 @@ impl DiagnosticId {
DiagnosticId::RevealedType => "revealed-type",
DiagnosticId::UnknownRule => "unknown-rule",
DiagnosticId::InvalidGlob => "invalid-glob",
DiagnosticId::EmptyInclude => "empty-include",
DiagnosticId::UnnecessaryOverridesSection => "unnecessary-overrides-section",
DiagnosticId::UselessOverridesSection => "useless-overrides-section",
}
}

View file

@ -59,6 +59,13 @@ pub fn max_parallelism() -> NonZeroUsize {
})
}
/// Trait for types that can provide Rust documentation.
///
/// Use `derive(RustDoc)` to automatically implement this trait for types that have a static string documentation.
pub trait RustDoc {
fn rust_doc() -> &'static str;
}
#[cfg(test)]
mod tests {
use std::sync::{Arc, Mutex};

View file

@ -92,7 +92,7 @@ impl Db for ModuleDb {
!file.path(self).is_vendored_path()
}
fn rule_selection(&self) -> &RuleSelection {
fn rule_selection(&self, _file: File) -> &RuleSelection {
&self.rule_selection
}

View file

@ -16,6 +16,7 @@ mod map_codes;
mod newtype_index;
mod rule_code_prefix;
mod rule_namespace;
mod rust_doc;
mod violation_metadata;
#[proc_macro_derive(OptionsMetadata, attributes(option, doc, option_group))]
@ -27,6 +28,15 @@ pub fn derive_options_metadata(input: TokenStream) -> TokenStream {
.into()
}
#[proc_macro_derive(RustDoc)]
pub fn derive_rust_doc(input: TokenStream) -> TokenStream {
let input = parse_macro_input!(input as DeriveInput);
rust_doc::derive_impl(input)
.unwrap_or_else(syn::Error::into_compile_error)
.into()
}
#[proc_macro_derive(CombineOptions)]
pub fn derive_combine_options(input: TokenStream) -> TokenStream {
let input = parse_macro_input!(input as DeriveInput);

View file

@ -0,0 +1,62 @@
use proc_macro2::TokenStream;
use quote::quote;
use syn::{Attribute, DeriveInput, Error, Lit, LitStr, Meta};
pub(crate) fn derive_impl(input: DeriveInput) -> syn::Result<TokenStream> {
let docs = get_docs(&input.attrs)?;
let name = input.ident;
let (impl_generics, ty_generics, where_clause) = &input.generics.split_for_impl();
Ok(quote! {
#[automatically_derived]
impl #impl_generics ruff_db::RustDoc for #name #ty_generics #where_clause {
fn rust_doc() -> &'static str {
#docs
}
}
})
}
/// Collect all doc comment attributes into a string
fn get_docs(attrs: &[Attribute]) -> syn::Result<String> {
let mut explanation = String::new();
for attr in attrs {
if attr.path().is_ident("doc") {
if let Some(lit) = parse_attr(["doc"], attr) {
let value = lit.value();
// `/// ` adds
let line = value.strip_prefix(' ').unwrap_or(&value);
explanation.push_str(line);
explanation.push('\n');
} else {
return Err(Error::new_spanned(attr, "unimplemented doc comment style"));
}
}
}
Ok(explanation)
}
fn parse_attr<'a, const LEN: usize>(
path: [&'static str; LEN],
attr: &'a Attribute,
) -> Option<&'a LitStr> {
if let Meta::NameValue(name_value) = &attr.meta {
let path_idents = name_value
.path
.segments
.iter()
.map(|segment| &segment.ident);
if path_idents.eq(path) {
if let syn::Expr::Lit(syn::ExprLit {
lit: Lit::Str(lit), ..
}) = &name_value.value
{
return Some(lit);
}
}
}
None
}

View file

@ -151,6 +151,116 @@ typeshed = "/path/to/custom/typeshed"
---
## `overrides`
Configuration override that applies to specific files based on glob patterns.
An override allows you to apply different rule configurations to specific
files or directories. Multiple overrides can match the same file, with
later overrides take precedence.
### Precedence
- Later overrides in the array take precedence over earlier ones
- Override rules take precedence over global rules for matching files
### Examples
```toml
# Relax rules for test files
[[tool.ty.overrides]]
include = ["tests/**", "**/test_*.py"]
[tool.ty.overrides.rules]
possibly-unresolved-reference = "warn"
# Ignore generated files but still check important ones
[[tool.ty.overrides]]
include = ["generated/**"]
exclude = ["generated/important.py"]
[tool.ty.overrides.rules]
possibly-unresolved-reference = "ignore"
```
#### `exclude`
A list of file and directory patterns to exclude from this override.
Patterns follow a syntax similar to `.gitignore`.
Exclude patterns take precedence over include patterns within the same override.
If not specified, defaults to `[]` (excludes no files).
**Default value**: `null`
**Type**: `list[str]`
**Example usage** (`pyproject.toml`):
```toml
[[tool.ty.overrides]]
exclude = [
"generated",
"*.proto",
"tests/fixtures/**",
"!tests/fixtures/important.py" # Include this one file
]
```
---
#### `include`
A list of file and directory patterns to include for this override.
The `include` option follows a similar syntax to `.gitignore` but reversed:
Including a file or directory will make it so that it (and its contents)
are affected by this override.
If not specified, defaults to `["**"]` (matches all files).
**Default value**: `null`
**Type**: `list[str]`
**Example usage** (`pyproject.toml`):
```toml
[[tool.ty.overrides]]
include = [
"src",
"tests",
]
```
---
#### `rules`
Rule overrides for files matching the include/exclude patterns.
These rules will be merged with the global rules, with override rules
taking precedence for matching files. You can set rules to different
severity levels or disable them entirely.
**Default value**: `{...}`
**Type**: `dict[RuleName, "ignore" | "warn" | "error"]`
**Example usage** (`pyproject.toml`):
```toml
[[tool.ty.overrides]]
include = ["src"]
[tool.ty.overrides.rules]
possibly-unresolved-reference = "ignore"
```
---
## `src`
#### `exclude`
@ -214,6 +324,45 @@ exclude = [
---
#### `include`
A list of files and directories to check. The `include` option
follows a similar syntax to `.gitignore` but reversed:
Including a file or directory will make it so that it (and its contents)
are type checked.
- `./src/` matches only a directory
- `./src` matches both files and directories
- `src` matches a file or directory named `src`
- `*` matches any (possibly empty) sequence of characters (except `/`).
- `**` matches zero or more path components.
This sequence **must** form a single path component, so both `**a` and `b**` are invalid and will result in an error.
A sequence of more than two consecutive `*` characters is also invalid.
- `?` matches any single character except `/`
- `[abc]` matches any character inside the brackets. Character sequences can also specify ranges of characters, as ordered by Unicode,
so e.g. `[0-9]` specifies any character between `0` and `9` inclusive. An unclosed bracket is invalid.
Unlike `exclude`, all paths are anchored relative to the project root (`src` only
matches `<project_root>/src` and not `<project_root>/test/src`).
`exclude` takes precedence over `include`.
**Default value**: `null`
**Type**: `list[str]`
**Example usage** (`pyproject.toml`):
```toml
[tool.ty.src]
include = [
"src",
"tests",
]
```
---
#### `respect-ignore-files`
Whether to automatically exclude files that are ignored by `.ignore`,

View file

@ -205,14 +205,17 @@ impl CheckCommand {
src: Some(SrcOptions {
respect_ignore_files,
exclude: self.exclude.map(|excludes| {
excludes
.iter()
.map(|exclude| RelativeExcludePattern::cli(exclude))
.collect()
RangedValue::cli(
excludes
.iter()
.map(|exclude| RelativeExcludePattern::cli(exclude))
.collect(),
)
}),
..SrcOptions::default()
}),
rules,
..Options::default()
};
// Merge with options passed in via --config
options.combine(self.config.into_options().unwrap_or_default())

View file

@ -128,7 +128,7 @@ fn cli_config_args_later_overrides_earlier() -> anyhow::Result<()> {
#[test]
fn cli_config_args_invalid_option() -> anyhow::Result<()> {
let case = CliTest::with_file("test.py", r"print(1)")?;
assert_cmd_snapshot!(case.command().arg("--config").arg("bad-option=true"), @r###"
assert_cmd_snapshot!(case.command().arg("--config").arg("bad-option=true"), @r"
success: false
exit_code: 2
----- stdout -----
@ -138,13 +138,13 @@ fn cli_config_args_invalid_option() -> anyhow::Result<()> {
|
1 | bad-option=true
| ^^^^^^^^^^
unknown field `bad-option`, expected one of `environment`, `src`, `rules`, `terminal`
unknown field `bad-option`, expected one of `environment`, `src`, `rules`, `terminal`, `overrides`
Usage: ty <COMMAND>
For more information, try '--help'.
"###);
");
Ok(())
}

View file

@ -639,7 +639,7 @@ fn invalid_include_pattern() -> anyhow::Result<()> {
2 | [src]
3 | include = [
4 | "src/**test/"
| ^^^^^^^^^^^^^ Too many stars at position 5 in glob: `src/**test/`
| ^^^^^^^^^^^^^ Too many stars at position 5
5 | ]
|
"#);
@ -676,7 +676,7 @@ fn invalid_include_pattern_concise_output() -> anyhow::Result<()> {
----- stderr -----
WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors.
ty failed
Cause: error[invalid-glob] ty.toml:4:5: Invalid include pattern: Too many stars at position 5 in glob: `src/**test/`
Cause: error[invalid-glob] ty.toml:4:5: Invalid include pattern: Too many stars at position 5
");
Ok(())
@ -717,7 +717,7 @@ fn invalid_exclude_pattern() -> anyhow::Result<()> {
2 | [src]
3 | exclude = [
4 | "../src"
| ^^^^^^^^ The parent directory operator (`..`) at position 1 is not allowed in glob: `../src`
| ^^^^^^^^ The parent directory operator (`..`) at position 1 is not allowed
5 | ]
|
"#);

View file

@ -290,3 +290,613 @@ fn cli_unknown_rules() -> anyhow::Result<()> {
Ok(())
}
/// Basic override functionality: override rules for specific files
#[test]
fn overrides_basic() -> anyhow::Result<()> {
let case = CliTest::with_files([
(
"pyproject.toml",
r#"
[tool.ty.rules]
division-by-zero = "error"
unresolved-reference = "error"
[[tool.ty.overrides]]
include = ["tests/**"]
[tool.ty.overrides.rules]
division-by-zero = "warn"
unresolved-reference = "ignore"
"#,
),
(
"main.py",
r#"
y = 4 / 0 # division-by-zero: error (global)
x = 1
prin(x) # unresolved-reference: error (global)
"#,
),
(
"tests/test_main.py",
r#"
y = 4 / 0 # division-by-zero: warn (override)
x = 1
prin(x) # unresolved-reference: ignore (override)
"#,
),
])?;
assert_cmd_snapshot!(case.command(), @r###"
success: false
exit_code: 1
----- stdout -----
error[division-by-zero]: Cannot divide object of type `Literal[4]` by zero
--> main.py:2:5
|
2 | y = 4 / 0 # division-by-zero: error (global)
| ^^^^^
3 | x = 1
4 | prin(x) # unresolved-reference: error (global)
|
info: rule `division-by-zero` was selected in the configuration file
error[unresolved-reference]: Name `prin` used when not defined
--> main.py:4:1
|
2 | y = 4 / 0 # division-by-zero: error (global)
3 | x = 1
4 | prin(x) # unresolved-reference: error (global)
| ^^^^
|
info: rule `unresolved-reference` was selected in the configuration file
warning[division-by-zero]: Cannot divide object of type `Literal[4]` by zero
--> tests/test_main.py:2:5
|
2 | y = 4 / 0 # division-by-zero: warn (override)
| ^^^^^
3 | x = 1
4 | prin(x) # unresolved-reference: ignore (override)
|
info: rule `division-by-zero` was selected in the configuration file
Found 3 diagnostics
----- stderr -----
WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors.
"###);
Ok(())
}
/// Multiple overrides: later overrides take precedence
#[test]
fn overrides_precedence() -> anyhow::Result<()> {
let case = CliTest::with_files([
(
"pyproject.toml",
r#"
[tool.ty.rules]
division-by-zero = "error"
# First override: all test files
[[tool.ty.overrides]]
include = ["tests/**"]
[tool.ty.overrides.rules]
division-by-zero = "warn"
# Second override: specific test file (takes precedence)
[[tool.ty.overrides]]
include = ["tests/important.py"]
[tool.ty.overrides.rules]
division-by-zero = "ignore"
"#,
),
(
"tests/test_main.py",
r#"
y = 4 / 0 # division-by-zero: warn (first override)
"#,
),
(
"tests/important.py",
r#"
y = 4 / 0 # division-by-zero: ignore (second override)
"#,
),
])?;
assert_cmd_snapshot!(case.command(), @r"
success: true
exit_code: 0
----- stdout -----
warning[division-by-zero]: Cannot divide object of type `Literal[4]` by zero
--> tests/test_main.py:2:5
|
2 | y = 4 / 0 # division-by-zero: warn (first override)
| ^^^^^
|
info: rule `division-by-zero` was selected in the configuration file
Found 1 diagnostic
----- stderr -----
WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors.
");
Ok(())
}
/// Override with exclude patterns
#[test]
fn overrides_exclude() -> anyhow::Result<()> {
let case = CliTest::with_files([
(
"pyproject.toml",
r#"
[tool.ty.rules]
division-by-zero = "error"
[[tool.ty.overrides]]
include = ["tests/**"]
exclude = ["tests/important.py"]
[tool.ty.overrides.rules]
division-by-zero = "warn"
"#,
),
(
"tests/test_main.py",
r#"
y = 4 / 0 # division-by-zero: warn (override applies)
"#,
),
(
"tests/important.py",
r#"
y = 4 / 0 # division-by-zero: error (override excluded)
"#,
),
])?;
assert_cmd_snapshot!(case.command(), @r"
success: false
exit_code: 1
----- stdout -----
error[division-by-zero]: Cannot divide object of type `Literal[4]` by zero
--> tests/important.py:2:5
|
2 | y = 4 / 0 # division-by-zero: error (override excluded)
| ^^^^^
|
info: rule `division-by-zero` was selected in the configuration file
warning[division-by-zero]: Cannot divide object of type `Literal[4]` by zero
--> tests/test_main.py:2:5
|
2 | y = 4 / 0 # division-by-zero: warn (override applies)
| ^^^^^
|
info: rule `division-by-zero` was selected in the configuration file
Found 2 diagnostics
----- stderr -----
WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors.
");
Ok(())
}
/// Override without rules inherits global rules
#[test]
fn overrides_inherit_global() -> anyhow::Result<()> {
let case = CliTest::with_files([
(
"pyproject.toml",
r#"
[tool.ty.rules]
division-by-zero = "warn"
unresolved-reference = "error"
[[tool.ty.overrides]]
include = ["tests/**"]
[tool.ty.overrides.rules]
# Override only division-by-zero, unresolved-reference should inherit from global
division-by-zero = "ignore"
"#,
),
(
"main.py",
r#"
y = 4 / 0 # division-by-zero: warn (global)
prin(y) # unresolved-reference: error (global)
"#,
),
(
"tests/test_main.py",
r#"
y = 4 / 0 # division-by-zero: ignore (overridden)
prin(y) # unresolved-reference: error (inherited from global)
"#,
),
])?;
assert_cmd_snapshot!(case.command(), @r#"
success: false
exit_code: 1
----- stdout -----
warning[division-by-zero]: Cannot divide object of type `Literal[4]` by zero
--> main.py:2:5
|
2 | y = 4 / 0 # division-by-zero: warn (global)
| ^^^^^
3 | prin(y) # unresolved-reference: error (global)
|
info: rule `division-by-zero` was selected in the configuration file
error[unresolved-reference]: Name `prin` used when not defined
--> main.py:3:1
|
2 | y = 4 / 0 # division-by-zero: warn (global)
3 | prin(y) # unresolved-reference: error (global)
| ^^^^
|
info: rule `unresolved-reference` was selected in the configuration file
error[unresolved-reference]: Name `prin` used when not defined
--> tests/test_main.py:3:1
|
2 | y = 4 / 0 # division-by-zero: ignore (overridden)
3 | prin(y) # unresolved-reference: error (inherited from global)
| ^^^^
|
info: rule `unresolved-reference` was selected in the configuration file
Found 3 diagnostics
----- stderr -----
WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors.
"#);
Ok(())
}
/// ty warns about invalid glob patterns in override include patterns
#[test]
fn overrides_invalid_include_glob() -> anyhow::Result<()> {
let case = CliTest::with_files([
(
"pyproject.toml",
r#"
[tool.ty.rules]
division-by-zero = "error"
[[tool.ty.overrides]]
include = ["tests/[invalid"] # Invalid glob: unclosed bracket
[tool.ty.overrides.rules]
division-by-zero = "warn"
"#,
),
(
"test.py",
r#"
y = 4 / 0
"#,
),
])?;
assert_cmd_snapshot!(case.command(), @r#"
success: false
exit_code: 2
----- stdout -----
----- stderr -----
WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors.
ty failed
Cause: error[invalid-glob]: Invalid include pattern
--> pyproject.toml:6:12
|
5 | [[tool.ty.overrides]]
6 | include = ["tests/[invalid"] # Invalid glob: unclosed bracket
| ^^^^^^^^^^^^^^^^ unclosed character class; missing ']'
7 | [tool.ty.overrides.rules]
8 | division-by-zero = "warn"
|
"#);
Ok(())
}
/// ty warns about invalid glob patterns in override exclude patterns
#[test]
fn overrides_invalid_exclude_glob() -> anyhow::Result<()> {
let case = CliTest::with_files([
(
"pyproject.toml",
r#"
[tool.ty.rules]
division-by-zero = "error"
[[tool.ty.overrides]]
include = ["tests/**"]
exclude = ["***/invalid"] # Invalid glob: triple asterisk
[tool.ty.overrides.rules]
division-by-zero = "warn"
"#,
),
(
"test.py",
r#"
y = 4 / 0
"#,
),
])?;
assert_cmd_snapshot!(case.command(), @r#"
success: false
exit_code: 2
----- stdout -----
----- stderr -----
WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors.
ty failed
Cause: error[invalid-glob]: Invalid exclude pattern
--> pyproject.toml:7:12
|
5 | [[tool.ty.overrides]]
6 | include = ["tests/**"]
7 | exclude = ["***/invalid"] # Invalid glob: triple asterisk
| ^^^^^^^^^^^^^ Too many stars at position 1
8 | [tool.ty.overrides.rules]
9 | division-by-zero = "warn"
|
"#);
Ok(())
}
/// ty warns when an overrides section has neither include nor exclude
#[test]
fn overrides_missing_include_exclude() -> anyhow::Result<()> {
let case = CliTest::with_files([
(
"pyproject.toml",
r#"
[tool.ty.rules]
division-by-zero = "error"
[[tool.ty.overrides]]
# Missing both include and exclude - should warn
[tool.ty.overrides.rules]
division-by-zero = "warn"
"#,
),
(
"test.py",
r#"
y = 4 / 0
"#,
),
])?;
assert_cmd_snapshot!(case.command(), @r#"
success: true
exit_code: 0
----- stdout -----
warning[unnecessary-overrides-section]: Unnecessary `overrides` section
--> pyproject.toml:5:1
|
3 | division-by-zero = "error"
4 |
5 | [[tool.ty.overrides]]
| ^^^^^^^^^^^^^^^^^^^^^ This overrides section applies to all files
6 | # Missing both include and exclude - should warn
7 | [tool.ty.overrides.rules]
|
info: It has no `include` or `exclude` option restricting the files
info: Restrict the files by adding a pattern to `include` or `exclude`...
info: or remove the `[[overrides]]` section and merge the configuration into the root `[rules]` table if the configuration should apply to all files
warning[division-by-zero]: Cannot divide object of type `Literal[4]` by zero
--> test.py:2:5
|
2 | y = 4 / 0
| ^^^^^
|
info: rule `division-by-zero` was selected in the configuration file
Found 2 diagnostics
----- stderr -----
WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors.
"#);
Ok(())
}
/// ty warns when an overrides section has an empty include array
#[test]
fn overrides_empty_include() -> anyhow::Result<()> {
let case = CliTest::with_files([
(
"pyproject.toml",
r#"
[tool.ty.rules]
division-by-zero = "error"
[[tool.ty.overrides]]
include = [] # Empty include - won't match any files
[tool.ty.overrides.rules]
division-by-zero = "warn"
"#,
),
(
"test.py",
r#"
y = 4 / 0
"#,
),
])?;
assert_cmd_snapshot!(case.command(), @r#"
success: false
exit_code: 1
----- stdout -----
warning[empty-include]: Empty include matches no files
--> pyproject.toml:6:11
|
5 | [[tool.ty.overrides]]
6 | include = [] # Empty include - won't match any files
| ^^ This `include` list is empty
7 | [tool.ty.overrides.rules]
8 | division-by-zero = "warn"
|
info: Remove the `include` option to match all files or add a pattern to match specific files
error[division-by-zero]: Cannot divide object of type `Literal[4]` by zero
--> test.py:2:5
|
2 | y = 4 / 0
| ^^^^^
|
info: rule `division-by-zero` was selected in the configuration file
Found 2 diagnostics
----- stderr -----
WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors.
"#);
Ok(())
}
/// ty warns when an overrides section has no actual overrides
#[test]
fn overrides_no_actual_overrides() -> anyhow::Result<()> {
let case = CliTest::with_files([
(
"pyproject.toml",
r#"
[tool.ty.rules]
division-by-zero = "error"
[[tool.ty.overrides]]
include = ["*.py"] # Has patterns but no rule overrides
# Missing [tool.ty.overrides.rules] section entirely
"#,
),
(
"test.py",
r#"
y = 4 / 0
"#,
),
])?;
assert_cmd_snapshot!(case.command(), @r#"
success: false
exit_code: 1
----- stdout -----
warning[useless-overrides-section]: Useless `overrides` section
--> pyproject.toml:5:1
|
3 | division-by-zero = "error"
4 |
5 | / [[tool.ty.overrides]]
6 | | include = ["*.py"] # Has patterns but no rule overrides
| |__________________^ This overrides section configures no rules
7 | # Missing [tool.ty.overrides.rules] section entirely
|
info: It has no `rules` table
info: Add a `[overrides.rules]` table...
info: or remove the `[[overrides]]` section if there's nothing to override
error[division-by-zero]: Cannot divide object of type `Literal[4]` by zero
--> test.py:2:5
|
2 | y = 4 / 0
| ^^^^^
|
info: rule `division-by-zero` was selected in the configuration file
Found 2 diagnostics
----- stderr -----
WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors.
"#);
Ok(())
}
/// ty warns about unknown rules specified in an overrides section
#[test]
fn overrides_unknown_rules() -> anyhow::Result<()> {
let case = CliTest::with_files([
(
"pyproject.toml",
r#"
[tool.ty.rules]
division-by-zero = "error"
[[tool.ty.overrides]]
include = ["tests/**"]
[tool.ty.overrides.rules]
division-by-zero = "warn"
division-by-zer = "error" # incorrect rule name
"#,
),
(
"main.py",
r#"
y = 4 / 0
"#,
),
(
"tests/test_main.py",
r#"
y = 4 / 0
"#,
),
])?;
assert_cmd_snapshot!(case.command(), @r#"
success: false
exit_code: 1
----- stdout -----
warning[unknown-rule]: Unknown lint rule `division-by-zer`
--> pyproject.toml:10:1
|
8 | [tool.ty.overrides.rules]
9 | division-by-zero = "warn"
10 | division-by-zer = "error" # incorrect rule name
| ^^^^^^^^^^^^^^^
|
error[division-by-zero]: Cannot divide object of type `Literal[4]` by zero
--> main.py:2:5
|
2 | y = 4 / 0
| ^^^^^
|
info: rule `division-by-zero` was selected in the configuration file
warning[division-by-zero]: Cannot divide object of type `Literal[4]` by zero
--> tests/test_main.py:2:5
|
2 | y = 4 / 0
| ^^^^^
|
info: rule `division-by-zero` was selected in the configuration file
Found 3 diagnostics
----- stderr -----
WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors.
"#);
Ok(())
}

View file

@ -120,7 +120,7 @@ pub(crate) mod tests {
!file.path(self).is_vendored_path()
}
fn rule_selection(&self) -> &RuleSelection {
fn rule_selection(&self, _file: File) -> &RuleSelection {
&self.rule_selection
}

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)
}
}

View file

@ -7,7 +7,8 @@ use ruff_db::{Db as SourceDb, Upcast};
pub trait Db: SourceDb + Upcast<dyn SourceDb> {
fn is_file_open(&self, file: File) -> bool;
fn rule_selection(&self) -> &RuleSelection;
/// Resolves the rule selection for a given file.
fn rule_selection(&self, file: File) -> &RuleSelection;
fn lint_registry(&self) -> &LintRegistry;
}
@ -126,7 +127,7 @@ pub(crate) mod tests {
!file.path(self).is_vendored_path()
}
fn rule_selection(&self) -> &RuleSelection {
fn rule_selection(&self, _file: File) -> &RuleSelection {
&self.rule_selection
}

View file

@ -32,7 +32,7 @@ pub struct LintMetadata {
pub line: u32,
}
#[derive(Copy, Clone, Debug, Eq, PartialEq, Ord, PartialOrd)]
#[derive(Copy, Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)]
#[cfg_attr(
feature = "serde",
derive(serde::Serialize, serde::Deserialize),

View file

@ -1,10 +1,12 @@
use std::fmt::{Display, Formatter};
use ruff_macros::RustDoc;
/// The target platform to assume when resolving types.
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(
feature = "serde",
derive(serde::Serialize, serde::Deserialize),
derive(serde::Serialize, serde::Deserialize, RustDoc),
serde(rename_all = "kebab-case")
)]
pub enum PythonPlatform {
@ -57,6 +59,7 @@ impl Default for PythonPlatform {
#[cfg(feature = "schemars")]
mod schema {
use crate::PythonPlatform;
use ruff_db::RustDoc;
use schemars::_serde_json::Value;
use schemars::JsonSchema;
use schemars::r#gen::SchemaGenerator;
@ -121,6 +124,10 @@ mod schema {
..SubschemaValidation::default()
})),
metadata: Some(Box::new(Metadata {
description: Some(<PythonPlatform as RustDoc>::rust_doc().to_string()),
..Metadata::default()
})),
..SchemaObject::default()
})

View file

@ -290,7 +290,10 @@ impl<'a> CheckSuppressionsContext<'a> {
}
fn is_lint_disabled(&self, lint: &'static LintMetadata) -> bool {
!self.db.rule_selection().is_enabled(LintId::of(lint))
!self
.db
.rule_selection(self.file)
.is_enabled(LintId::of(lint))
}
fn report_lint(
@ -315,7 +318,7 @@ impl<'a> CheckSuppressionsContext<'a> {
range: TextRange,
message: fmt::Arguments,
) {
let Some(severity) = self.db.rule_selection().severity(LintId::of(lint)) else {
let Some(severity) = self.db.rule_selection(self.file).severity(LintId::of(lint)) else {
return;
};

View file

@ -401,7 +401,7 @@ impl<'db, 'ctx> LintDiagnosticGuardBuilder<'db, 'ctx> {
let lint_id = LintId::of(lint);
// Skip over diagnostics if the rule
// is disabled.
let (severity, source) = ctx.db.rule_selection().get(lint_id)?;
let (severity, source) = ctx.db.rule_selection(ctx.file).get(lint_id)?;
// If we're not in type checking mode,
// we can bail now.
if ctx.is_in_no_type_check() {

View file

@ -260,7 +260,7 @@ impl ty_python_semantic::Db for CorpusDb {
!file.path(self).is_vendored_path()
}
fn rule_selection(&self) -> &RuleSelection {
fn rule_selection(&self, _file: File) -> &RuleSelection {
&self.rule_selection
}

View file

@ -90,7 +90,7 @@ impl SemanticDb for Db {
!file.path(self).is_vendored_path()
}
fn rule_selection(&self) -> &RuleSelection {
fn rule_selection(&self, _file: File) -> &RuleSelection {
&self.rule_selection
}

View file

@ -18,8 +18,8 @@ use ruff_python_parser::{Mode, ParseOptions, parse_unchecked};
use ty_python_semantic::lint::LintRegistry;
use ty_python_semantic::types::check_types;
use ty_python_semantic::{
Db as SemanticDb, Program, ProgramSettings, PythonPlatform, SearchPathSettings,
default_lint_registry, lint::RuleSelection, PythonVersionWithSource,
Db as SemanticDb, Program, ProgramSettings, PythonPlatform, PythonVersionWithSource,
SearchPathSettings, default_lint_registry, lint::RuleSelection,
};
/// Database that can be used for testing.
@ -95,7 +95,7 @@ impl SemanticDb for TestDb {
!file.path(self).is_vendored_path()
}
fn rule_selection(&self) -> &RuleSelection {
fn rule_selection(&self, _file: File) -> &RuleSelection {
&self.rule_selection
}

50
ty.schema.json generated
View file

@ -14,6 +14,16 @@
}
]
},
"overrides": {
"description": "Override configurations for specific file patterns.\n\nEach override specifies include/exclude patterns and rule configurations that apply to matching files. Multiple overrides can match the same file, with later overrides taking precedence.",
"type": [
"array",
"null"
],
"items": {
"$ref": "#/definitions/OverrideOptions"
}
},
"rules": {
"description": "Configures the enabled rules and their severity.\n\nSee [the rules documentation](https://ty.dev/rules) for a list of all available rules.\n\nValid severities are:\n\n* `ignore`: Disable the rule. * `warn`: Enable the rule and create a warning diagnostic. * `error`: Enable the rule and create an error diagnostic. ty will exit with a non-zero code if any error diagnostics are emitted.",
"anyOf": [
@ -147,7 +157,45 @@
}
]
},
"OverrideOptions": {
"type": "object",
"properties": {
"exclude": {
"description": "A list of file and directory patterns to exclude from this override.\n\nPatterns follow a syntax similar to `.gitignore`. Exclude patterns take precedence over include patterns within the same override.\n\nIf not specified, defaults to `[]` (excludes no files).",
"type": [
"array",
"null"
],
"items": {
"type": "string"
}
},
"include": {
"description": "A list of file and directory patterns to include for this override.\n\nThe `include` option follows a similar syntax to `.gitignore` but reversed: Including a file or directory will make it so that it (and its contents) are affected by this override.\n\nIf not specified, defaults to `[\"**\"]` (matches all files).",
"type": [
"array",
"null"
],
"items": {
"type": "string"
}
},
"rules": {
"description": "Rule overrides for files matching the include/exclude patterns.\n\nThese rules will be merged with the global rules, with override rules taking precedence for matching files. You can set rules to different severity levels or disable them entirely.",
"anyOf": [
{
"$ref": "#/definitions/Rules"
},
{
"type": "null"
}
]
}
},
"additionalProperties": false
},
"PythonPlatform": {
"description": "The target platform to assume when resolving types.\n",
"anyOf": [
{
"type": "string"
@ -882,7 +930,7 @@
}
},
"include": {
"description": "A list of files and directories to check. The `include` option follows a similar syntax to `.gitignore` but reversed: Including a file or directory will make it so that it (and its contents) are type checked.\n\n- `./src/` matches only a directory - `./src` matches both files and directories - `src` matches files or directories named `src` anywhere in the tree (e.g. `./src` or `./tests/src`) - `*` matches any (possibly empty) sequence of characters (except `/`). - `**` matches zero or more path components. This sequence **must** form a single path component, so both `**a` and `b**` are invalid and will result in an error. A sequence of more than two consecutive `*` characters is also invalid. - `?` matches any single character except `/` - `[abc]` matches any character inside the brackets. Character sequences can also specify ranges of characters, as ordered by Unicode, so e.g. `[0-9]` specifies any character between `0` and `9` inclusive. An unclosed bracket is invalid.\n\nUnlike `exclude`, all paths are anchored relative to the project root (`src` only matches `<project_root>/src` and not `<project_root>/test/src`).\n\n`exclude` take precedence over `include`.",
"description": "A list of files and directories to check. The `include` option follows a similar syntax to `.gitignore` but reversed: Including a file or directory will make it so that it (and its contents) are type checked.\n\n- `./src/` matches only a directory - `./src` matches both files and directories - `src` matches a file or directory named `src` - `*` matches any (possibly empty) sequence of characters (except `/`). - `**` matches zero or more path components. This sequence **must** form a single path component, so both `**a` and `b**` are invalid and will result in an error. A sequence of more than two consecutive `*` characters is also invalid. - `?` matches any single character except `/` - `[abc]` matches any character inside the brackets. Character sequences can also specify ranges of characters, as ordered by Unicode, so e.g. `[0-9]` specifies any character between `0` and `9` inclusive. An unclosed bracket is invalid.\n\nUnlike `exclude`, all paths are anchored relative to the project root (`src` only matches `<project_root>/src` and not `<project_root>/test/src`).\n\n`exclude` takes precedence over `include`.",
"type": [
"array",
"null"