[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| {
RangedValue::cli(
excludes
.iter()
.map(|exclude| RelativeExcludePattern::cli(exclude))
.collect()
.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]

View file

@ -1,25 +1,29 @@
use crate::Db;
use crate::glob::{
ExcludeFilterBuilder, IncludeExcludeFilter, IncludeFilterBuilder, PortableGlobPattern,
};
use crate::metadata::settings::SrcSettings;
use crate::combine::Combine;
use crate::glob::{ExcludeFilter, IncludeExcludeFilter, IncludeFilter};
use crate::metadata::settings::{OverrideSettings, SrcSettings};
use crate::metadata::value::{
RangedValue, RelativeExcludePattern, RelativeIncludePattern, RelativePathBuf, ValueSource,
ValueSourceGuard,
};
use ordermap::OrderMap;
use ruff_db::RustDoc;
use ruff_db::diagnostic::{
Annotation, Diagnostic, DiagnosticFormat, DiagnosticId, DisplayDiagnosticConfig, Severity,
Span, SubDiagnostic,
};
use ruff_db::files::system_path_to_file;
use ruff_db::system::{System, SystemPath, SystemPathBuf};
use ruff_macros::{Combine, OptionsMetadata};
use ruff_macros::{Combine, OptionsMetadata, RustDoc};
use ruff_options_metadata::{OptionSet, OptionsMetadata, Visit};
use ruff_python_ast::PythonVersion;
use rustc_hash::FxHashMap;
use rustc_hash::FxHasher;
use serde::{Deserialize, Serialize};
use std::borrow::Cow;
use std::fmt::{self, Debug, Display};
use std::hash::BuildHasherDefault;
use std::ops::Deref;
use std::sync::Arc;
use thiserror::Error;
use ty_python_semantic::lint::{GetLintError, Level, LintSource, RuleSelection};
@ -28,7 +32,7 @@ use ty_python_semantic::{
PythonVersionWithSource, SearchPathSettings, SysPrefixPathOrigin,
};
use super::settings::{Settings, TerminalSettings};
use super::settings::{Override, Settings, TerminalSettings};
#[derive(
Debug, Default, Clone, PartialEq, Eq, Combine, Serialize, Deserialize, OptionsMetadata,
@ -70,6 +74,15 @@ pub struct Options {
#[serde(skip_serializing_if = "Option::is_none")]
#[option_group]
pub terminal: Option<TerminalOptions>,
/// Override configurations for specific file patterns.
///
/// Each 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.
#[serde(skip_serializing_if = "Option::is_none")]
#[option_group]
pub overrides: Option<OverridesOptions>,
}
impl Options {
@ -228,7 +241,8 @@ impl Options {
db: &dyn Db,
project_root: &SystemPath,
) -> Result<(Settings, Vec<OptionDiagnostic>), ToSettingsError> {
let (rules, diagnostics) = self.to_rule_selection(db);
let mut diagnostics = Vec::new();
let rules = self.to_rule_selection(db, &mut diagnostics);
let terminal_options = self.terminal.clone().unwrap_or_default();
let terminal = TerminalSettings {
@ -247,7 +261,15 @@ impl Options {
};
let src = src_options
.to_settings(db, project_root)
.to_settings(db, project_root, &mut diagnostics)
.map_err(|err| ToSettingsError {
diagnostic: err,
output_format: terminal.output_format,
color: colored::control::SHOULD_COLORIZE.should_colorize(),
})?;
let overrides = self
.to_overrides_settings(db, project_root, &mut diagnostics)
.map_err(|err| ToSettingsError {
diagnostic: err,
output_format: terminal.output_format,
@ -258,80 +280,45 @@ impl Options {
rules: Arc::new(rules),
terminal,
src,
overrides,
};
Ok((settings, diagnostics))
}
#[must_use]
fn to_rule_selection(&self, db: &dyn Db) -> (RuleSelection, Vec<OptionDiagnostic>) {
let registry = db.lint_registry();
let mut diagnostics = Vec::new();
// Initialize the selection with the defaults
let mut selection = RuleSelection::from_registry(registry);
let rules = self
.rules
.as_ref()
.into_iter()
.flat_map(|rules| rules.inner.iter());
for (rule_name, level) in rules {
let source = rule_name.source();
match registry.get(rule_name) {
Ok(lint) => {
let lint_source = match source {
ValueSource::File(_) => LintSource::File,
ValueSource::Cli => LintSource::Cli,
};
if let Ok(severity) = Severity::try_from(**level) {
selection.enable(lint, severity, lint_source);
fn to_rule_selection(
&self,
db: &dyn Db,
diagnostics: &mut Vec<OptionDiagnostic>,
) -> RuleSelection {
if let Some(rules) = self.rules.as_ref() {
rules.to_rule_selection(db, diagnostics)
} else {
selection.disable(lint);
}
}
Err(error) => {
// `system_path_to_file` can return `Err` if the file was deleted since the configuration
// was read. This should be rare and it should be okay to default to not showing a configuration
// file in that case.
let file = source
.file()
.and_then(|path| system_path_to_file(db.upcast(), path).ok());
// TODO: Add a note if the value was configured on the CLI
let diagnostic = match error {
GetLintError::Unknown(_) => OptionDiagnostic::new(
DiagnosticId::UnknownRule,
format!("Unknown lint rule `{rule_name}`"),
Severity::Warning,
),
GetLintError::PrefixedWithCategory { suggestion, .. } => {
OptionDiagnostic::new(
DiagnosticId::UnknownRule,
format!(
"Unknown lint rule `{rule_name}`. Did you mean `{suggestion}`?"
),
Severity::Warning,
)
}
GetLintError::Removed(_) => OptionDiagnostic::new(
DiagnosticId::UnknownRule,
format!("Unknown lint rule `{rule_name}`"),
Severity::Warning,
),
};
let annotation = file.map(Span::from).map(|span| {
Annotation::primary(span.with_optional_range(rule_name.range()))
});
diagnostics.push(diagnostic.with_annotation(annotation));
}
RuleSelection::from_registry(db.lint_registry())
}
}
(selection, diagnostics)
fn to_overrides_settings(
&self,
db: &dyn Db,
project_root: &SystemPath,
diagnostics: &mut Vec<OptionDiagnostic>,
) -> Result<Vec<Override>, Box<OptionDiagnostic>> {
let override_options = self.overrides.as_deref().unwrap_or_default();
let mut overrides = Vec::with_capacity(override_options.len());
for override_option in override_options {
let override_instance =
override_option.to_override(db, project_root, self.rules.as_ref(), diagnostics)?;
if let Some(value) = override_instance {
overrides.push(value);
}
}
Ok(overrides)
}
}
@ -479,7 +466,7 @@ pub struct SrcOptions {
///
/// - `./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`)
/// - `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.
@ -491,9 +478,19 @@ pub struct SrcOptions {
/// Unlike `exclude`, all paths are anchored relative to the project root (`src` only
/// matches `<project_root>/src` and not `<project_root>/test/src`).
///
/// `exclude` take precedence over `include`.
/// `exclude` takes precedence over `include`.
#[serde(skip_serializing_if = "Option::is_none")]
pub include: Option<Vec<RelativeIncludePattern>>,
#[option(
default = r#"null"#,
value_type = r#"list[str]"#,
example = r#"
include = [
"src",
"tests",
]
"#
)]
pub include: Option<RangedValue<Vec<RelativeIncludePattern>>>,
/// A list of file and directory patterns to exclude from type checking.
///
@ -548,7 +545,7 @@ pub struct SrcOptions {
"#
)]
#[serde(skip_serializing_if = "Option::is_none")]
pub exclude: Option<Vec<RelativeExcludePattern>>,
pub exclude: Option<RangedValue<Vec<RelativeExcludePattern>>>,
}
impl SrcOptions {
@ -556,75 +553,125 @@ impl SrcOptions {
&self,
db: &dyn Db,
project_root: &SystemPath,
diagnostics: &mut Vec<OptionDiagnostic>,
) -> Result<SrcSettings, Box<OptionDiagnostic>> {
let mut includes = IncludeFilterBuilder::new();
let system = db.system();
let include = build_include_filter(
db,
project_root,
self.include.as_ref(),
GlobFilterContext::SrcRoot,
diagnostics,
)?;
let exclude = build_exclude_filter(
db,
project_root,
self.exclude.as_ref(),
DEFAULT_SRC_EXCLUDES,
GlobFilterContext::SrcRoot,
)?;
let files = IncludeExcludeFilter::new(include, exclude);
if let Some(include) = self.include.as_ref() {
for pattern in include {
// Check the relative pattern for better error messages.
pattern.absolute(project_root, system)
.and_then(|include| Ok(includes.add(&include)?))
.map_err(|err| {
let diagnostic = OptionDiagnostic::new(
DiagnosticId::InvalidGlob,
format!("Invalid include pattern: {err}"),
Severity::Error,
);
Ok(SrcSettings {
respect_ignore_files: self.respect_ignore_files.unwrap_or(true),
files,
})
}
}
match pattern.source() {
ValueSource::File(file_path) => {
if let Ok(file) = system_path_to_file(db.upcast(), &**file_path) {
diagnostic
.with_message("Invalid include pattern")
.with_annotation(Some(
Annotation::primary(
Span::from(file)
.with_optional_range(pattern.range()),
)
.message(err.to_string()),
))
#[derive(Debug, Default, Clone, Eq, PartialEq, Combine, Serialize, Deserialize, Hash)]
#[serde(rename_all = "kebab-case", transparent)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct Rules {
#[cfg_attr(feature = "schemars", schemars(with = "schema::Rules"))]
inner: OrderMap<RangedValue<String>, RangedValue<Level>, BuildHasherDefault<FxHasher>>,
}
impl FromIterator<(RangedValue<String>, RangedValue<Level>)> for Rules {
fn from_iter<T: IntoIterator<Item = (RangedValue<String>, RangedValue<Level>)>>(
iter: T,
) -> Self {
Self {
inner: iter.into_iter().collect(),
}
}
}
impl Rules {
/// Convert the rules to a `RuleSelection` with diagnostics.
pub fn to_rule_selection(
&self,
db: &dyn Db,
diagnostics: &mut Vec<OptionDiagnostic>,
) -> RuleSelection {
let registry = db.lint_registry();
// Initialize the selection with the defaults
let mut selection = RuleSelection::from_registry(registry);
for (rule_name, level) in &self.inner {
let source = rule_name.source();
match registry.get(rule_name) {
Ok(lint) => {
let lint_source = match source {
ValueSource::File(_) => LintSource::File,
ValueSource::Cli => LintSource::Cli,
};
if let Ok(severity) = Severity::try_from(**level) {
selection.enable(lint, severity, lint_source);
} else {
diagnostic.sub(Some(SubDiagnostic::new(
Severity::Info,
"The pattern is defined in the `src.include` option in your configuration file",
)))
selection.disable(lint);
}
}
ValueSource::Cli => diagnostic.sub(Some(SubDiagnostic::new(
Severity::Info,
"The pattern was specified on the CLI using `--include`",
))),
}
})?;
}
} else {
includes
.add(
&PortableGlobPattern::parse("**", false)
.unwrap()
.into_absolute(""),
Err(error) => {
// `system_path_to_file` can return `Err` if the file was deleted since the configuration
// was read. This should be rare and it should be okay to default to not showing a configuration
// file in that case.
let file = source
.file()
.and_then(|path| system_path_to_file(db.upcast(), path).ok());
// TODO: Add a note if the value was configured on the CLI
let diagnostic = match error {
GetLintError::Unknown(_) => OptionDiagnostic::new(
DiagnosticId::UnknownRule,
format!("Unknown lint rule `{rule_name}`"),
Severity::Warning,
),
GetLintError::PrefixedWithCategory { suggestion, .. } => {
OptionDiagnostic::new(
DiagnosticId::UnknownRule,
format!(
"Unknown lint rule `{rule_name}`. Did you mean `{suggestion}`?"
),
Severity::Warning,
)
.unwrap();
}
let include = includes.build().map_err(|_| {
// https://github.com/BurntSushi/ripgrep/discussions/2927
let diagnostic = OptionDiagnostic::new(
DiagnosticId::InvalidGlob,
"The `src.include` patterns resulted in a regex that is too large".to_string(),
Severity::Error,
);
diagnostic.sub(Some(SubDiagnostic::new(
Severity::Info,
"Please open an issue on the ty repository and share the pattern that caused the error.",
)))
})?;
GetLintError::Removed(_) => OptionDiagnostic::new(
DiagnosticId::UnknownRule,
format!("Unknown lint rule `{rule_name}`"),
Severity::Warning,
),
};
let mut excludes = ExcludeFilterBuilder::new();
let annotation = file.map(Span::from).map(|span| {
Annotation::primary(span.with_optional_range(rule_name.range()))
});
diagnostics.push(diagnostic.with_annotation(annotation));
}
}
}
// Add the default excludes first, so that a user can override them with a negated exclude pattern.
for pattern in [
selection
}
pub(super) fn is_empty(&self) -> bool {
self.inner.is_empty()
}
}
/// Default exclude patterns for src options.
const DEFAULT_SRC_EXCLUDES: &[&str] = &[
".bzr",
".direnv",
".eggs",
@ -645,24 +692,137 @@ impl SrcOptions {
"dist",
"node_modules",
"venv",
] {
];
/// Helper function to build an include filter from patterns with proper error handling.
fn build_include_filter(
db: &dyn Db,
project_root: &SystemPath,
include_patterns: Option<&RangedValue<Vec<RelativeIncludePattern>>>,
context: GlobFilterContext,
diagnostics: &mut Vec<OptionDiagnostic>,
) -> Result<IncludeFilter, Box<OptionDiagnostic>> {
use crate::glob::{IncludeFilterBuilder, PortableGlobPattern};
let system = db.system();
let mut includes = IncludeFilterBuilder::new();
if let Some(include_patterns) = include_patterns {
if include_patterns.is_empty() {
// An override with an empty include `[]` won't match any files.
let mut diagnostic = OptionDiagnostic::new(
DiagnosticId::EmptyInclude,
"Empty include matches no files".to_string(),
Severity::Warning,
)
.sub(SubDiagnostic::new(
Severity::Info,
"Remove the `include` option to match all files or add a pattern to match specific files",
));
// Add source annotation if we have source information
if let Some(source_file) = include_patterns.source().file() {
if let Ok(file) = system_path_to_file(db.upcast(), source_file) {
let annotation = Annotation::primary(
Span::from(file).with_optional_range(include_patterns.range()),
)
.message("This `include` list is empty");
diagnostic = diagnostic.with_annotation(Some(annotation));
}
}
diagnostics.push(diagnostic);
}
for pattern in include_patterns {
pattern.absolute(project_root, system)
.and_then(|include| Ok(includes.add(&include)?))
.map_err(|err| {
let diagnostic = OptionDiagnostic::new(
DiagnosticId::InvalidGlob,
format!("Invalid include pattern `{pattern}`: {err}"),
Severity::Error,
);
match pattern.source() {
ValueSource::File(file_path) => {
if let Ok(file) = system_path_to_file(db.upcast(), &**file_path) {
diagnostic
.with_message("Invalid include pattern")
.with_annotation(Some(
Annotation::primary(
Span::from(file)
.with_optional_range(pattern.range()),
)
.message(err.to_string()),
))
} else {
diagnostic.sub(SubDiagnostic::new(
Severity::Info,
format!("The pattern is defined in the `{}` option in your configuration file", context.include_name()),
))
}
}
ValueSource::Cli => diagnostic.sub(SubDiagnostic::new(
Severity::Info,
"The pattern was specified on the CLI",
)),
}
})?;
}
} else {
includes
.add(
&PortableGlobPattern::parse("**", false)
.unwrap()
.into_absolute(""),
)
.unwrap();
}
includes.build().map_err(|_| {
let diagnostic = OptionDiagnostic::new(
DiagnosticId::InvalidGlob,
format!("The `{}` patterns resulted in a regex that is too large", context.include_name()),
Severity::Error,
);
Box::new(diagnostic.sub(SubDiagnostic::new(
Severity::Info,
"Please open an issue on the ty repository and share the patterns that caused the error.",
)))
})
}
/// Helper function to build an exclude filter from patterns with proper error handling.
fn build_exclude_filter(
db: &dyn Db,
project_root: &SystemPath,
exclude_patterns: Option<&RangedValue<Vec<RelativeExcludePattern>>>,
default_patterns: &[&str],
context: GlobFilterContext,
) -> Result<ExcludeFilter, Box<OptionDiagnostic>> {
use crate::glob::{ExcludeFilterBuilder, PortableGlobPattern};
let system = db.system();
let mut excludes = ExcludeFilterBuilder::new();
for pattern in default_patterns {
PortableGlobPattern::parse(pattern, true)
.and_then(|exclude| Ok(excludes.add(&exclude.into_absolute(""))?))
.unwrap_or_else(|err| {
panic!(
"Expected default exclude to be valid glob but adding it failed with: {err}"
)
panic!("Expected default exclude to be valid glob but adding it failed with: {err}")
});
}
for exclude in self.exclude.as_deref().unwrap_or_default() {
// Check the relative path for better error messages.
// Add user-specified excludes
if let Some(exclude_patterns) = exclude_patterns {
for exclude in exclude_patterns {
exclude.absolute(project_root, system)
.and_then(|pattern| Ok(excludes.add(&pattern)?))
.map_err(|err| {
let diagnostic = OptionDiagnostic::new(
DiagnosticId::InvalidGlob,
format!("Invalid exclude pattern: {err}"),
format!("Invalid exclude pattern `{exclude}`: {err}"),
Severity::Error,
);
@ -679,54 +839,55 @@ impl SrcOptions {
.message(err.to_string()),
))
} else {
diagnostic.sub(Some(SubDiagnostic::new(
diagnostic.sub(SubDiagnostic::new(
Severity::Info,
"The pattern is defined in the `src.exclude` option in your configuration file",
)))
format!("The pattern is defined in the `{}` option in your configuration file", context.exclude_name()),
))
}
}
ValueSource::Cli => diagnostic.sub(Some(SubDiagnostic::new(
ValueSource::Cli => diagnostic.sub(SubDiagnostic::new(
Severity::Info,
"The pattern was specified on the CLI using `--exclude`",
))),
"The pattern was specified on the CLI",
)),
}
})?;
}
}
let exclude = excludes.build().map_err(|_| {
// https://github.com/BurntSushi/ripgrep/discussions/2927
excludes.build().map_err(|_| {
let diagnostic = OptionDiagnostic::new(
DiagnosticId::InvalidGlob,
"The `src.exclude` patterns resulted in a regex that is too large".to_string(),
format!("The `{}` patterns resulted in a regex that is too large", context.exclude_name()),
Severity::Error,
);
diagnostic.sub(Some(SubDiagnostic::new(
Box::new(diagnostic.sub(SubDiagnostic::new(
Severity::Info,
"Please open an issue on the ty repository and share the pattern that caused the error.",
"Please open an issue on the ty repository and share the patterns that caused the error.",
)))
})?;
Ok(SrcSettings {
respect_ignore_files: self.respect_ignore_files.unwrap_or(true),
files: IncludeExcludeFilter::new(include, exclude),
})
}
/// Context for filter operations, used in error messages
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum GlobFilterContext {
/// Source root configuration context
SrcRoot,
/// Override configuration context
Overrides,
}
impl GlobFilterContext {
fn include_name(self) -> &'static str {
match self {
Self::SrcRoot => "src.include",
Self::Overrides => "overrides.include",
}
}
}
#[derive(Debug, Default, Clone, Eq, PartialEq, Combine, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case", transparent)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct Rules {
#[cfg_attr(feature = "schemars", schemars(with = "schema::Rules"))]
inner: FxHashMap<RangedValue<String>, RangedValue<Level>>,
}
impl FromIterator<(RangedValue<String>, RangedValue<Level>)> for Rules {
fn from_iter<T: IntoIterator<Item = (RangedValue<String>, RangedValue<Level>)>>(
iter: T,
) -> Self {
Self {
inner: iter.into_iter().collect(),
fn exclude_name(self) -> &'static str {
match self {
Self::SrcRoot => "src.exclude",
Self::Overrides => "overrides.exclude",
}
}
}
@ -763,6 +924,284 @@ pub struct TerminalOptions {
pub error_on_warning: Option<bool>,
}
/// 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"
/// ```
#[derive(Debug, Default, Clone, PartialEq, Eq, Combine, Serialize, Deserialize, RustDoc)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[serde(transparent)]
pub struct OverridesOptions(Vec<RangedValue<OverrideOptions>>);
impl OptionsMetadata for OverridesOptions {
fn documentation() -> Option<&'static str> {
Some(<Self as RustDoc>::rust_doc())
}
fn record(visit: &mut dyn Visit) {
OptionSet::of::<OverrideOptions>().record(visit);
}
}
impl Deref for OverridesOptions {
type Target = [RangedValue<OverrideOptions>];
fn deref(&self) -> &Self::Target {
&self.0
}
}
#[derive(
Debug, Default, Clone, Eq, PartialEq, Combine, Serialize, Deserialize, OptionsMetadata,
)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct OverrideOptions {
/// 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).
#[serde(skip_serializing_if = "Option::is_none")]
#[option(
default = r#"null"#,
value_type = r#"list[str]"#,
example = r#"
[[tool.ty.overrides]]
include = [
"src",
"tests",
]
"#
)]
pub include: Option<RangedValue<Vec<RelativeIncludePattern>>>,
/// 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).
#[serde(skip_serializing_if = "Option::is_none")]
#[option(
default = r#"null"#,
value_type = r#"list[str]"#,
example = r#"
[[tool.ty.overrides]]
exclude = [
"generated",
"*.proto",
"tests/fixtures/**",
"!tests/fixtures/important.py" # Include this one file
]
"#
)]
pub exclude: Option<RangedValue<Vec<RelativeExcludePattern>>>,
/// 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.
#[serde(skip_serializing_if = "Option::is_none")]
#[option(
default = r#"{...}"#,
value_type = r#"dict[RuleName, "ignore" | "warn" | "error"]"#,
example = r#"
[[tool.ty.overrides]]
include = ["src"]
[tool.ty.overrides.rules]
possibly-unresolved-reference = "ignore"
"#
)]
pub rules: Option<Rules>,
}
impl RangedValue<OverrideOptions> {
fn to_override(
&self,
db: &dyn Db,
project_root: &SystemPath,
global_rules: Option<&Rules>,
diagnostics: &mut Vec<OptionDiagnostic>,
) -> Result<Option<Override>, Box<OptionDiagnostic>> {
// First, warn about incorrect or useless overrides.
if self.rules.as_ref().is_none_or(Rules::is_empty) {
let mut diagnostic = OptionDiagnostic::new(
DiagnosticId::UselessOverridesSection,
"Useless `overrides` section".to_string(),
Severity::Warning,
);
diagnostic = if self.rules.is_none() {
diagnostic = diagnostic.sub(SubDiagnostic::new(
Severity::Info,
"It has no `rules` table",
));
diagnostic.sub(SubDiagnostic::new(
Severity::Info,
"Add a `[overrides.rules]` table...",
))
} else {
diagnostic = diagnostic.sub(SubDiagnostic::new(
Severity::Info,
"The rules table is empty",
));
diagnostic.sub(SubDiagnostic::new(
Severity::Info,
"Add a rule to `[overrides.rules]` to override specific rules...",
))
};
diagnostic = diagnostic.sub(SubDiagnostic::new(
Severity::Info,
"or remove the `[[overrides]]` section if there's nothing to override",
));
// Add source annotation if we have source information
if let Some(source_file) = self.source().file() {
if let Ok(file) = system_path_to_file(db.upcast(), source_file) {
let annotation =
Annotation::primary(Span::from(file).with_optional_range(self.range()))
.message("This overrides section configures no rules");
diagnostic = diagnostic.with_annotation(Some(annotation));
}
}
diagnostics.push(diagnostic);
// Return `None`, because this override doesn't override anything
return Ok(None);
}
let include_missing = self.include.is_none();
let exclude_empty = self
.exclude
.as_ref()
.is_none_or(|exclude| exclude.is_empty());
if include_missing && exclude_empty {
// Neither include nor exclude specified - applies to all files
let mut diagnostic = OptionDiagnostic::new(
DiagnosticId::UnnecessaryOverridesSection,
"Unnecessary `overrides` section".to_string(),
Severity::Warning,
);
diagnostic = if self.exclude.is_none() {
diagnostic.sub(SubDiagnostic::new(
Severity::Info,
"It has no `include` or `exclude` option restricting the files",
))
} else {
diagnostic.sub(SubDiagnostic::new(
Severity::Info,
"It has no `include` option and `exclude` is empty",
))
};
diagnostic = diagnostic.sub(SubDiagnostic::new(
Severity::Info,
"Restrict the files by adding a pattern to `include` or `exclude`...",
));
diagnostic = diagnostic.sub(SubDiagnostic::new(
Severity::Info,
"or remove the `[[overrides]]` section and merge the configuration into the root `[rules]` table if the configuration should apply to all files",
));
// Add source annotation if we have source information
if let Some(source_file) = self.source().file() {
if let Ok(file) = system_path_to_file(db.upcast(), source_file) {
let annotation =
Annotation::primary(Span::from(file).with_optional_range(self.range()))
.message("This overrides section applies to all files");
diagnostic = diagnostic.with_annotation(Some(annotation));
}
}
diagnostics.push(diagnostic);
}
// The override is at least (partially) valid.
// Construct the matcher and resolve the settings.
let include = build_include_filter(
db,
project_root,
self.include.as_ref(),
GlobFilterContext::Overrides,
diagnostics,
)?;
let exclude = build_exclude_filter(
db,
project_root,
self.exclude.as_ref(),
&[],
GlobFilterContext::Overrides,
)?;
let files = IncludeExcludeFilter::new(include, exclude);
// Merge global rules with override rules, with override rules taking precedence
let merged_rules = self
.rules
.clone()
.combine(global_rules.cloned())
.expect("method to have early returned if rules is None");
// Convert merged rules to rule selection
let rule_selection = merged_rules.to_rule_selection(db, diagnostics);
let override_instance = Override {
files,
options: Arc::new(InnerOverrideOptions {
rules: self.rules.clone(),
}),
settings: Arc::new(OverrideSettings {
rules: rule_selection,
}),
};
Ok(Some(override_instance))
}
}
/// The options for an override but without the include/exclude patterns.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Combine)]
pub(super) struct InnerOverrideOptions {
/// Raw rule options as specified in the configuration.
/// Used when multiple overrides match a file and need to be merged.
pub(super) rules: Option<Rules>,
}
/// Error returned when the settings can't be resolved because of a hard error.
#[derive(Debug)]
pub struct ToSettingsError {
@ -886,7 +1325,7 @@ pub struct OptionDiagnostic {
message: String,
severity: Severity,
annotation: Option<Annotation>,
sub: Option<SubDiagnostic>,
sub: Vec<SubDiagnostic>,
}
impl OptionDiagnostic {
@ -896,7 +1335,7 @@ impl OptionDiagnostic {
message,
severity,
annotation: None,
sub: None,
sub: Vec::new(),
}
}
@ -914,8 +1353,9 @@ impl OptionDiagnostic {
}
#[must_use]
fn sub(self, sub: Option<SubDiagnostic>) -> Self {
OptionDiagnostic { sub, ..self }
fn sub(mut self, sub: SubDiagnostic) -> Self {
self.sub.push(sub);
self
}
pub(crate) fn to_diagnostic(&self) -> Diagnostic {
@ -923,6 +1363,11 @@ impl OptionDiagnostic {
if let Some(annotation) = self.annotation.clone() {
diag.annotate(annotation);
}
for sub in &self.sub {
diag.sub(sub.clone());
}
diag
}
}

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"