From 3a430fa6da045f145fb86463c0d74c8fd5d7ea54 Mon Sep 17 00:00:00 2001 From: Micha Reiser Date: Sun, 15 Jun 2025 15:27:39 +0200 Subject: [PATCH] [ty] Allow overriding rules for specific files (#18648) --- Cargo.lock | 2 + crates/ruff_db/src/diagnostic/mod.rs | 70 ++ crates/ruff_db/src/lib.rs | 7 + crates/ruff_graph/src/db.rs | 2 +- crates/ruff_macros/src/lib.rs | 10 + crates/ruff_macros/src/rust_doc.rs | 62 ++ crates/ty/docs/configuration.md | 149 +++ crates/ty/src/args.rs | 11 +- crates/ty/tests/cli/config_option.rs | 6 +- crates/ty/tests/cli/file_selection.rs | 6 +- crates/ty/tests/cli/rule_selection.rs | 610 +++++++++++++ crates/ty_ide/src/db.rs | 2 +- crates/ty_project/Cargo.toml | 1 + crates/ty_project/src/combine.rs | 34 + crates/ty_project/src/db.rs | 8 +- crates/ty_project/src/glob.rs | 6 + crates/ty_project/src/glob/exclude.rs | 20 +- crates/ty_project/src/glob/include.rs | 25 +- crates/ty_project/src/glob/portable.rs | 125 ++- crates/ty_project/src/metadata/options.rs | 853 +++++++++++++----- crates/ty_project/src/metadata/settings.rs | 151 +++- crates/ty_project/src/metadata/value.rs | 12 + crates/ty_python_semantic/src/db.rs | 5 +- crates/ty_python_semantic/src/lint.rs | 2 +- .../ty_python_semantic/src/python_platform.rs | 9 +- crates/ty_python_semantic/src/suppression.rs | 7 +- .../ty_python_semantic/src/types/context.rs | 2 +- crates/ty_python_semantic/tests/corpus.rs | 2 +- crates/ty_test/src/db.rs | 2 +- fuzz/fuzz_targets/ty_check_invalid_syntax.rs | 6 +- ty.schema.json | 50 +- 31 files changed, 1945 insertions(+), 312 deletions(-) create mode 100644 crates/ruff_macros/src/rust_doc.rs diff --git a/Cargo.lock b/Cargo.lock index 03aa31a04b..9ff33789d0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/crates/ruff_db/src/diagnostic/mod.rs b/crates/ruff_db/src/diagnostic/mod.rs index 07ad6371e5..6caefbe0c9 100644 --- a/crates/ruff_db/src/diagnostic/mod.rs +++ b/crates/ruff_db/src/diagnostic/mod.rs @@ -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", } } diff --git a/crates/ruff_db/src/lib.rs b/crates/ruff_db/src/lib.rs index c970a99d12..dec4500c5d 100644 --- a/crates/ruff_db/src/lib.rs +++ b/crates/ruff_db/src/lib.rs @@ -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}; diff --git a/crates/ruff_graph/src/db.rs b/crates/ruff_graph/src/db.rs index 89eb974cc8..b7056ece58 100644 --- a/crates/ruff_graph/src/db.rs +++ b/crates/ruff_graph/src/db.rs @@ -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 } diff --git a/crates/ruff_macros/src/lib.rs b/crates/ruff_macros/src/lib.rs index 1f8089b711..858b5db567 100644 --- a/crates/ruff_macros/src/lib.rs +++ b/crates/ruff_macros/src/lib.rs @@ -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); diff --git a/crates/ruff_macros/src/rust_doc.rs b/crates/ruff_macros/src/rust_doc.rs new file mode 100644 index 0000000000..229f4322f3 --- /dev/null +++ b/crates/ruff_macros/src/rust_doc.rs @@ -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 { + 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 { + 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 +} diff --git a/crates/ty/docs/configuration.md b/crates/ty/docs/configuration.md index 25cdb70a75..1ff58a5437 100644 --- a/crates/ty/docs/configuration.md +++ b/crates/ty/docs/configuration.md @@ -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 `/src` and not `/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`, diff --git a/crates/ty/src/args.rs b/crates/ty/src/args.rs index 99f0428f0c..2dc77377dd 100644 --- a/crates/ty/src/args.rs +++ b/crates/ty/src/args.rs @@ -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()) diff --git a/crates/ty/tests/cli/config_option.rs b/crates/ty/tests/cli/config_option.rs index 4ea24fa4f3..4732b28236 100644 --- a/crates/ty/tests/cli/config_option.rs +++ b/crates/ty/tests/cli/config_option.rs @@ -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 For more information, try '--help'. - "###); + "); Ok(()) } diff --git a/crates/ty/tests/cli/file_selection.rs b/crates/ty/tests/cli/file_selection.rs index 5e646a07cd..7c47e53bf5 100644 --- a/crates/ty/tests/cli/file_selection.rs +++ b/crates/ty/tests/cli/file_selection.rs @@ -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 | ] | "#); diff --git a/crates/ty/tests/cli/rule_selection.rs b/crates/ty/tests/cli/rule_selection.rs index d60c229263..a78b38b403 100644 --- a/crates/ty/tests/cli/rule_selection.rs +++ b/crates/ty/tests/cli/rule_selection.rs @@ -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(()) +} diff --git a/crates/ty_ide/src/db.rs b/crates/ty_ide/src/db.rs index ff91bb3377..9428292b1c 100644 --- a/crates/ty_ide/src/db.rs +++ b/crates/ty_ide/src/db.rs @@ -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 } diff --git a/crates/ty_project/Cargo.toml b/crates/ty_project/Cargo.toml index 707c9915ae..d0648255fd 100644 --- a/crates/ty_project/Cargo.toml +++ b/crates/ty_project/Cargo.toml @@ -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 } diff --git a/crates/ty_project/src/combine.rs b/crates/ty_project/src/combine.rs index 69d6eae9db..79fff36f77 100644 --- a/crates/ty_project/src/combine.rs +++ b/crates/ty_project/src/combine.rs @@ -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 Combine for OrderMap +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 = OrderMap::from_iter([(1, "a"), (2, "a"), (3, "a")]); + let b: OrderMap = 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") + ])) + ); + } } diff --git a/crates/ty_project/src/db.rs b/crates/ty_project/src/db.rs index ddcf8b95ab..654ae540c4 100644 --- a/crates/ty_project/src/db.rs +++ b/crates/ty_project/src/db.rs @@ -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) } diff --git a/crates/ty_project/src/glob.rs b/crates/ty_project/src/glob.rs index 2c93fc4716..f681dee13b 100644 --- a/crates/ty_project/src/glob.rs +++ b/crates/ty_project/src/glob.rs @@ -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 diff --git a/crates/ty_project/src/glob/exclude.rs b/crates/ty_project/src/glob/exclude.rs index 1e6813a00c..9a0b1a4093 100644 --- a/crates/ty_project/src/glob/exclude.rs +++ b/crates/ty_project/src/glob/exclude.rs @@ -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("\\!") { diff --git a/crates/ty_project/src/glob/include.rs b/crates/ty_project/src/glob/include.rs index b6365cf627..dc9e10dd48 100644 --- a/crates/ty_project/src/glob/include.rs +++ b/crates/ty_project/src/glob/include.rs @@ -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) diff --git a/crates/ty_project/src/glob/portable.rs b/crates/ty_project/src/glob/portable.rs index 8de8d91188..7b147c4a61 100644 --- a/crates/ty_project/src/glob/portable.rs +++ b/crates/ty_project/src/glob/portable.rs @@ -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, 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] diff --git a/crates/ty_project/src/metadata/options.rs b/crates/ty_project/src/metadata/options.rs index 6c7696278c..b81c8c43dc 100644 --- a/crates/ty_project/src/metadata/options.rs +++ b/crates/ty_project/src/metadata/options.rs @@ -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, + + /// 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, } impl Options { @@ -228,7 +241,8 @@ impl Options { db: &dyn Db, project_root: &SystemPath, ) -> Result<(Settings, Vec), 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) { - let registry = db.lint_registry(); - let mut diagnostics = Vec::new(); + fn to_rule_selection( + &self, + db: &dyn Db, + diagnostics: &mut Vec, + ) -> RuleSelection { + if let Some(rules) = self.rules.as_ref() { + rules.to_rule_selection(db, diagnostics) + } else { + RuleSelection::from_registry(db.lint_registry()) + } + } - // Initialize the selection with the defaults - let mut selection = RuleSelection::from_registry(registry); + fn to_overrides_settings( + &self, + db: &dyn Db, + project_root: &SystemPath, + diagnostics: &mut Vec, + ) -> Result, Box> { + let override_options = self.overrides.as_deref().unwrap_or_default(); - let rules = self - .rules - .as_ref() - .into_iter() - .flat_map(|rules| rules.inner.iter()); + let mut overrides = Vec::with_capacity(override_options.len()); - 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); - } 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()); + for override_option in override_options { + let override_instance = + override_option.to_override(db, project_root, self.rules.as_ref(), diagnostics)?; - // 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)); - } + if let Some(value) = override_instance { + overrides.push(value); } } - (selection, diagnostics) + 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 `/src` and not `/test/src`). /// - /// `exclude` take precedence over `include`. + /// `exclude` takes precedence over `include`. #[serde(skip_serializing_if = "Option::is_none")] - pub include: Option>, + #[option( + default = r#"null"#, + value_type = r#"list[str]"#, + example = r#" + include = [ + "src", + "tests", + ] + "# + )] + pub include: Option>>, /// 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>, + pub exclude: Option>>, } impl SrcOptions { @@ -556,113 +553,276 @@ impl SrcOptions { &self, db: &dyn Db, project_root: &SystemPath, + diagnostics: &mut Vec, ) -> Result> { - 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()), - )) - } else { - diagnostic.sub(Some(SubDiagnostic::new( - Severity::Info, - "The pattern is defined in the `src.include` option in your configuration file", - ))) - } - } - ValueSource::Cli => diagnostic.sub(Some(SubDiagnostic::new( - Severity::Info, - "The pattern was specified on the CLI using `--include`", - ))), +#[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, BuildHasherDefault>, +} + +impl FromIterator<(RangedValue, RangedValue)> for Rules { + fn from_iter, RangedValue)>>( + 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, + ) -> 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 { + 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)); + } } - } else { - includes - .add( - &PortableGlobPattern::parse("**", false) - .unwrap() - .into_absolute(""), - ) - .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( + 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", + ".git", + ".git-rewrite", + ".hg", + ".mypy_cache", + ".nox", + ".pants.d", + ".pytype", + ".ruff_cache", + ".svn", + ".tox", + ".venv", + "__pypackages__", + "_build", + "buck-out", + "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>>, + context: GlobFilterContext, + diagnostics: &mut Vec, +) -> Result> { + 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, - "Please open an issue on the ty repository and share the pattern that caused the error.", - ))) - })?; + "Remove the `include` option to match all files or add a pattern to match specific files", + )); - let mut excludes = ExcludeFilterBuilder::new(); - - // Add the default excludes first, so that a user can override them with a negated exclude pattern. - for pattern in [ - ".bzr", - ".direnv", - ".eggs", - ".git", - ".git-rewrite", - ".hg", - ".mypy_cache", - ".nox", - ".pants.d", - ".pytype", - ".ruff_cache", - ".svn", - ".tox", - ".venv", - "__pypackages__", - "_build", - "buck-out", - "dist", - "node_modules", - "venv", - ] { - 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}" + // 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 exclude in self.exclude.as_deref().unwrap_or_default() { - // Check the relative path for better error messages. + 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>>, + default_patterns: &[&str], + context: GlobFilterContext, +) -> Result> { + 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}") + }); + } + + // 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 - let diagnostic = OptionDiagnostic::new( - DiagnosticId::InvalidGlob, - "The `src.exclude` 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.", - ))) - })?; - - Ok(SrcSettings { - respect_ignore_files: self.respect_ignore_files.unwrap_or(true), - files: IncludeExcludeFilter::new(include, exclude), - }) } + + excludes.build().map_err(|_| { + let diagnostic = OptionDiagnostic::new( + DiagnosticId::InvalidGlob, + format!("The `{}` patterns resulted in a regex that is too large", context.exclude_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.", + ))) + }) } -#[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>, +/// 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 FromIterator<(RangedValue, RangedValue)> for Rules { - fn from_iter, RangedValue)>>( - iter: T, - ) -> Self { - Self { - inner: iter.into_iter().collect(), +impl GlobFilterContext { + fn include_name(self) -> &'static str { + match self { + Self::SrcRoot => "src.include", + Self::Overrides => "overrides.include", + } + } + + 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, } +/// 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>); + +impl OptionsMetadata for OverridesOptions { + fn documentation() -> Option<&'static str> { + Some(::rust_doc()) + } + + fn record(visit: &mut dyn Visit) { + OptionSet::of::().record(visit); + } +} + +impl Deref for OverridesOptions { + type Target = [RangedValue]; + + 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>>, + + /// 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>>, + + /// 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, +} + +impl RangedValue { + fn to_override( + &self, + db: &dyn Db, + project_root: &SystemPath, + global_rules: Option<&Rules>, + diagnostics: &mut Vec, + ) -> Result, Box> { + // 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, +} + /// 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, - sub: Option, + sub: Vec, } 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) -> 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 } } diff --git a/crates/ty_project/src/metadata/settings.rs b/crates/ty_project/src/metadata/settings.rs index 156f72632d..1c8424fd9c 100644 --- a/crates/ty_project/src/metadata/settings.rs +++ b/crates/ty_project/src/metadata/settings.rs @@ -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, 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, } 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, + + /// Pre-resolved rule selection for this override alone. + /// Used for efficient lookup when only this override matches a file. + pub(super) settings: Arc, +} + +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>, _: ()) -> 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), +} + +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, +} diff --git a/crates/ty_project/src/metadata/value.rs b/crates/ty_project/src/metadata/value.rs index 8e19e6b402..5d38185e13 100644 --- a/crates/ty_project/src/metadata/value.rs +++ b/crates/ty_project/src/metadata/value.rs @@ -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) + } +} diff --git a/crates/ty_python_semantic/src/db.rs b/crates/ty_python_semantic/src/db.rs index 11c6e29220..9e37ed3ca9 100644 --- a/crates/ty_python_semantic/src/db.rs +++ b/crates/ty_python_semantic/src/db.rs @@ -7,7 +7,8 @@ use ruff_db::{Db as SourceDb, Upcast}; pub trait Db: SourceDb + Upcast { 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 } diff --git a/crates/ty_python_semantic/src/lint.rs b/crates/ty_python_semantic/src/lint.rs index 2acaa24b0f..247bae7d78 100644 --- a/crates/ty_python_semantic/src/lint.rs +++ b/crates/ty_python_semantic/src/lint.rs @@ -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), diff --git a/crates/ty_python_semantic/src/python_platform.rs b/crates/ty_python_semantic/src/python_platform.rs index 587f777973..0822165322 100644 --- a/crates/ty_python_semantic/src/python_platform.rs +++ b/crates/ty_python_semantic/src/python_platform.rs @@ -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(::rust_doc().to_string()), + ..Metadata::default() + })), ..SchemaObject::default() }) diff --git a/crates/ty_python_semantic/src/suppression.rs b/crates/ty_python_semantic/src/suppression.rs index 26518ec7fa..b5103f6581 100644 --- a/crates/ty_python_semantic/src/suppression.rs +++ b/crates/ty_python_semantic/src/suppression.rs @@ -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; }; diff --git a/crates/ty_python_semantic/src/types/context.rs b/crates/ty_python_semantic/src/types/context.rs index f36b19873a..f2cf03fac9 100644 --- a/crates/ty_python_semantic/src/types/context.rs +++ b/crates/ty_python_semantic/src/types/context.rs @@ -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() { diff --git a/crates/ty_python_semantic/tests/corpus.rs b/crates/ty_python_semantic/tests/corpus.rs index 0a9222a3d6..c3c9b4297e 100644 --- a/crates/ty_python_semantic/tests/corpus.rs +++ b/crates/ty_python_semantic/tests/corpus.rs @@ -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 } diff --git a/crates/ty_test/src/db.rs b/crates/ty_test/src/db.rs index 348d61aa04..5574dd42d3 100644 --- a/crates/ty_test/src/db.rs +++ b/crates/ty_test/src/db.rs @@ -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 } diff --git a/fuzz/fuzz_targets/ty_check_invalid_syntax.rs b/fuzz/fuzz_targets/ty_check_invalid_syntax.rs index 5635d75c30..974a5ae0a9 100644 --- a/fuzz/fuzz_targets/ty_check_invalid_syntax.rs +++ b/fuzz/fuzz_targets/ty_check_invalid_syntax.rs @@ -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 } diff --git a/ty.schema.json b/ty.schema.json index cfef1c1aef..7485084b38 100644 --- a/ty.schema.json +++ b/ty.schema.json @@ -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 `/src` and not `/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 `/src` and not `/test/src`).\n\n`exclude` takes precedence over `include`.", "type": [ "array", "null"