diff --git a/crates/ty/docs/configuration.md b/crates/ty/docs/configuration.md index da093919b1..aada0abccc 100644 --- a/crates/ty/docs/configuration.md +++ b/crates/ty/docs/configuration.md @@ -444,6 +444,44 @@ severity levels or disable them entirely. --- +## `overrides.analysis` + +#### `respect-type-ignore-comments` + +Whether ty should respect `type: ignore` comments. + +When set to `false`, `type: ignore` comments are treated like any other normal +comment and can't be used to suppress ty errors (you have to use `ty: ignore` instead). + +Setting this option can be useful when using ty alongside other type checkers or when +you prefer using `ty: ignore` over `type: ignore`. + +Defaults to `true`. + +**Default value**: `true` + +**Type**: `bool` + +**Example usage**: + +=== "pyproject.toml" + + ```toml + [tool.ty.overrides.analysis] + # Disable support for `type: ignore` comments + respect-type-ignore-comments = false + ``` + +=== "ty.toml" + + ```toml + [overrides.analysis] + # Disable support for `type: ignore` comments + respect-type-ignore-comments = false + ``` + +--- + ## `src` ### `exclude` diff --git a/crates/ty/tests/cli/analysis_options.rs b/crates/ty/tests/cli/analysis_options.rs index 9967508c2b..3ebf4e276c 100644 --- a/crates/ty/tests/cli/analysis_options.rs +++ b/crates/ty/tests/cli/analysis_options.rs @@ -41,3 +41,172 @@ fn respect_type_ignore_comments_is_turned_off() -> anyhow::Result<()> { Ok(()) } + +/// Basic override functionality: override analysis options for a specific file +#[test] +fn overrides_basic() -> anyhow::Result<()> { + let case = CliTest::with_files([ + ( + "pyproject.toml", + r#" + [tool.ty.analysis] + respect-type-ignore-comments = true + + [[tool.ty.overrides]] + include = ["tests/**"] + + [tool.ty.overrides.analysis] + respect-type-ignore-comments = false + "#, + ), + ( + "main.py", + r#" + print(x) # type: ignore # ignore respected (global) + "#, + ), + ( + "tests/test_main.py", + r#" + print(x) # type: ignore # ignore not-respected (override) + "#, + ), + ])?; + + assert_cmd_snapshot!(case.command(), @" + success: false + exit_code: 1 + ----- stdout ----- + error[unresolved-reference]: Name `x` used when not defined + --> tests/test_main.py:2:7 + | + 2 | print(x) # type: ignore # ignore not-respected (override) + | ^ + | + info: rule `unresolved-reference` is enabled by default + + Found 1 diagnostic + + ----- stderr ----- + "); + + Ok(()) +} + +/// Multiple overrides: later overrides take precedence +#[test] +fn overrides_precedence() -> anyhow::Result<()> { + let case = CliTest::with_files([ + ( + "pyproject.toml", + r#" + [tool.ty.analysis] + respect-type-ignore-comments = true + + # First override: all test files + [[tool.ty.overrides]] + include = ["tests/**"] + [tool.ty.overrides.analysis] + respect-type-ignore-comments = false + + # Second override: specific test file (takes precedence) + [[tool.ty.overrides]] + include = ["tests/important.py"] + [tool.ty.overrides.analysis] + respect-type-ignore-comments = true + "#, + ), + ( + "tests/test_main.py", + r#" + print(y) # type: ignore (should be an error, because type ignores are disabled) + "#, + ), + ( + "tests/important.py", + r#" + print(y) # type: ignore (no error, because type ignores are enabled) + "#, + ), + ])?; + + assert_cmd_snapshot!(case.command(), @" + success: false + exit_code: 1 + ----- stdout ----- + error[unresolved-reference]: Name `y` used when not defined + --> tests/test_main.py:2:7 + | + 2 | print(y) # type: ignore (should be an error, because type ignores are disabled) + | ^ + | + info: rule `unresolved-reference` is enabled by default + + Found 1 diagnostic + + ----- stderr ----- + "); + + Ok(()) +} + +/// Override without analysis options inherit the global analysis options +#[test] +fn overrides_inherit_global() -> anyhow::Result<()> { + let case = CliTest::with_files([ + ( + "pyproject.toml", + r#" + [tool.ty.analysis] + respect-type-ignore-comments = false + + [[tool.ty.overrides]] + include = ["tests/**"] + + [tool.ty.overrides.rules] + division-by-zero = "warn" + + [tool.ty.overrides.analysis] + "#, + ), + ( + "main.py", + r#" + print(y) # type: ignore ignore not-respected (global) + "#, + ), + ( + "tests/test_main.py", + r#" + print(y) # type: ignore ignore respected (inherited from global) + "#, + ), + ])?; + + assert_cmd_snapshot!(case.command(), @" + success: false + exit_code: 1 + ----- stdout ----- + error[unresolved-reference]: Name `y` used when not defined + --> main.py:2:7 + | + 2 | print(y) # type: ignore ignore not-respected (global) + | ^ + | + info: rule `unresolved-reference` is enabled by default + + error[unresolved-reference]: Name `y` used when not defined + --> tests/test_main.py:2:7 + | + 2 | print(y) # type: ignore ignore respected (inherited from global) + | ^ + | + info: rule `unresolved-reference` is enabled by default + + Found 2 diagnostics + + ----- stderr ----- + "); + + Ok(()) +} diff --git a/crates/ty/tests/cli/rule_selection.rs b/crates/ty/tests/cli/rule_selection.rs index e0968bdd19..56b944e05b 100644 --- a/crates/ty/tests/cli/rule_selection.rs +++ b/crates/ty/tests/cli/rule_selection.rs @@ -796,12 +796,12 @@ fn overrides_no_actual_overrides() -> anyhow::Result<()> { 3 | division-by-zero = "error" 4 | 5 | [[tool.ty.overrides]] - | ^^^^^^^^^^^^^^^^^^^^^ This overrides section configures no rules + | ^^^^^^^^^^^^^^^^^^^^^ This overrides section overrides no settings 6 | include = ["*.py"] # Has patterns but no rule overrides 7 | # Missing [tool.ty.overrides.rules] section entirely | - info: It has no `rules` table - info: Add a `[overrides.rules]` table... + info: It has no `rules` or `analysis` table + info: Add a `[overrides.rules]` or `[overrides.analysis]` 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 diff --git a/crates/ty_project/src/db.rs b/crates/ty_project/src/db.rs index 8ad7848e93..92c1cc1f69 100644 --- a/crates/ty_project/src/db.rs +++ b/crates/ty_project/src/db.rs @@ -470,8 +470,9 @@ impl SemanticDb for ProjectDatabase { ty_python_semantic::default_lint_registry() } - fn analysis_settings(&self) -> &AnalysisSettings { - self.project().settings(self).analysis() + fn analysis_settings(&self, file: File) -> &AnalysisSettings { + let settings = file_settings(self, file); + settings.analysis(self) } fn verbose(&self) -> bool { @@ -661,7 +662,7 @@ pub(crate) mod tests { ty_python_semantic::default_lint_registry() } - fn analysis_settings(&self) -> &AnalysisSettings { + fn analysis_settings(&self, _file: ruff_db::files::File) -> &AnalysisSettings { self.project().settings(self).analysis() } diff --git a/crates/ty_project/src/metadata/options.rs b/crates/ty_project/src/metadata/options.rs index a250baa982..1eea1239ac 100644 --- a/crates/ty_project/src/metadata/options.rs +++ b/crates/ty_project/src/metadata/options.rs @@ -480,8 +480,13 @@ impl Options { let mut overrides = Vec::with_capacity(override_options.len()); for override_option in override_options { - let override_instance = - override_option.to_override(db, project_root, self.rules.as_ref(), diagnostics)?; + let override_instance = override_option.to_override( + db, + project_root, + self.rules.as_ref(), + self.analysis.as_ref(), + diagnostics, + )?; if let Some(value) = override_instance { overrides.push(value); @@ -1266,6 +1271,7 @@ pub struct TerminalOptions { Clone, Eq, PartialEq, + Hash, Combine, Serialize, Deserialize, @@ -1296,7 +1302,7 @@ pub struct AnalysisOptions { } impl AnalysisOptions { - fn to_settings(&self) -> AnalysisSettings { + pub(super) fn to_settings(&self) -> AnalysisSettings { let AnalysisSettings { respect_type_ignore_comments: respect_type_ignore_default, } = AnalysisSettings::default(); @@ -1446,6 +1452,10 @@ pub struct OverrideOptions { "# )] pub rules: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + #[option_group] + pub analysis: Option, } impl RangedValue { @@ -1454,36 +1464,49 @@ impl RangedValue { db: &dyn Db, project_root: &SystemPath, global_rules: Option<&Rules>, + global_analysis: Option<&AnalysisOptions>, diagnostics: &mut Vec, ) -> Result, Box> { let rules = self.rules.or_default(); + let analysis = self.analysis.or_default(); // First, warn about incorrect or useless overrides. - if rules.is_empty() { + if rules.is_empty() && *analysis == AnalysisOptions::default() { let mut diagnostic = OptionDiagnostic::new( DiagnosticId::UselessOverridesSection, "Useless `overrides` section".to_string(), Severity::Warning, ); - diagnostic = if self.rules.is_none() { + diagnostic = if self.rules.is_none() && self.analysis.is_none() { diagnostic = diagnostic.sub(SubDiagnostic::new( SubDiagnosticSeverity::Info, - "It has no `rules` table", + "It has no `rules` or `analysis` table", )); diagnostic.sub(SubDiagnostic::new( SubDiagnosticSeverity::Info, - "Add a `[overrides.rules]` table...", + "Add a `[overrides.rules]` or `[overrides.analysis]` table...", )) } else { - diagnostic = diagnostic.sub(SubDiagnostic::new( - SubDiagnosticSeverity::Info, - "The rules table is empty", - )); - diagnostic.sub(SubDiagnostic::new( - SubDiagnosticSeverity::Info, - "Add a rule to `[overrides.rules]` to override specific rules...", - )) + if self.rules.is_some() && rules.is_empty() { + diagnostic = diagnostic.sub(SubDiagnostic::new( + SubDiagnosticSeverity::Info, + "The `rules` table is empty", + )); + diagnostic = diagnostic.sub(SubDiagnostic::new( + SubDiagnosticSeverity::Info, + "Add a rule to `[overrides.rules]` to override specific rules...", + )); + } + + if self.analysis.is_some() && *analysis == AnalysisOptions::default() { + diagnostic = diagnostic.sub(SubDiagnostic::new( + SubDiagnosticSeverity::Info, + "The `analysis` table is empty", + )); + } + + diagnostic }; diagnostic = diagnostic.sub(SubDiagnostic::new( @@ -1496,7 +1519,7 @@ impl RangedValue { if let Ok(file) = system_path_to_file(db, source_file) { let annotation = Annotation::primary(Span::from(file).with_optional_range(self.range())) - .message("This overrides section configures no rules"); + .message("This overrides section overrides no settings"); diagnostic = diagnostic.with_annotation(Some(annotation)); } } @@ -1585,13 +1608,23 @@ impl RangedValue { // Convert merged rules to rule selection let rule_selection = merged_rules.to_rule_selection(db, diagnostics); + let mut merged_analysis = analysis.into_owned(); + + if let Some(global_analysis) = global_analysis { + merged_analysis = merged_analysis.combine(global_analysis.clone()); + } + + let analysis = merged_analysis.to_settings(); + let override_instance = Override { files, options: Arc::new(InnerOverrideOptions { rules: self.rules.clone(), + analysis: self.analysis.clone(), }), settings: Arc::new(OverrideSettings { rules: rule_selection, + analysis, }), }; @@ -1605,6 +1638,8 @@ 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, + + pub(super) analysis: Option, } /// Error returned when the settings can't be resolved because of a hard error. diff --git a/crates/ty_project/src/metadata/settings.rs b/crates/ty_project/src/metadata/settings.rs index 67e6ba2099..a8b20b5a68 100644 --- a/crates/ty_project/src/metadata/settings.rs +++ b/crates/ty_project/src/metadata/settings.rs @@ -171,18 +171,26 @@ fn merge_overrides(db: &dyn Db, overrides: Vec>, _: () merged.combine_with((*option).clone()); } - merged - .rules - .combine_with(db.project().metadata(db).options().rules.clone()); + let global_options = db.project().metadata(db).options(); - let Some(rules) = merged.rules else { + merged.rules.combine_with(global_options.rules.clone()); + merged + .analysis + .combine_with(global_options.analysis.clone()); + + if merged.rules.is_none() && merged.analysis.is_none() { return FileSettings::Global; - }; + } + + let rules = merged.rules.unwrap_or_default(); + let analysis = merged.analysis.unwrap_or_default(); // 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 })) + let analysis = analysis.to_settings(); + + FileSettings::File(Arc::new(OverrideSettings { rules, analysis })) } /// The resolved settings for a file. @@ -202,9 +210,17 @@ impl FileSettings { FileSettings::File(override_settings) => &override_settings.rules, } } + + pub fn analysis<'a>(&'a self, db: &'a dyn Db) -> &'a AnalysisSettings { + match self { + FileSettings::Global => db.project().settings(db).analysis(), + FileSettings::File(override_settings) => &override_settings.analysis, + } + } } #[derive(Debug, Eq, PartialEq, Clone, get_size2::GetSize)] pub struct OverrideSettings { pub(super) rules: RuleSelection, + pub(super) analysis: AnalysisSettings, } diff --git a/crates/ty_python_semantic/src/db.rs b/crates/ty_python_semantic/src/db.rs index b721a7baf5..f6a1a2f17c 100644 --- a/crates/ty_python_semantic/src/db.rs +++ b/crates/ty_python_semantic/src/db.rs @@ -14,7 +14,7 @@ pub trait Db: ModuleResolverDb { fn lint_registry(&self) -> &LintRegistry; - fn analysis_settings(&self) -> &AnalysisSettings; + fn analysis_settings(&self, file: File) -> &AnalysisSettings; /// Whether ty is running with logging verbosity INFO or higher (`-v` or more). fn verbose(&self) -> bool; @@ -138,7 +138,7 @@ pub(crate) mod tests { default_lint_registry() } - fn analysis_settings(&self) -> &AnalysisSettings { + fn analysis_settings(&self, _file: File) -> &AnalysisSettings { &self.analysis_settings } diff --git a/crates/ty_python_semantic/src/suppression.rs b/crates/ty_python_semantic/src/suppression.rs index 5161c65922..77bcf54609 100644 --- a/crates/ty_python_semantic/src/suppression.rs +++ b/crates/ty_python_semantic/src/suppression.rs @@ -105,7 +105,7 @@ pub(crate) fn suppressions(db: &dyn Db, file: File) -> Suppressions { let parsed = parsed_module(db, file).load(db); let source = source_text(db, file); - let respect_type_ignore = db.analysis_settings().respect_type_ignore_comments; + let respect_type_ignore = db.analysis_settings(file).respect_type_ignore_comments; let mut builder = SuppressionsBuilder::new(&source, db.lint_registry()); let mut line_start = TextSize::default(); diff --git a/crates/ty_python_semantic/tests/corpus.rs b/crates/ty_python_semantic/tests/corpus.rs index e9e7653746..8b522b086f 100644 --- a/crates/ty_python_semantic/tests/corpus.rs +++ b/crates/ty_python_semantic/tests/corpus.rs @@ -268,7 +268,7 @@ impl ty_python_semantic::Db for CorpusDb { false } - fn analysis_settings(&self) -> &AnalysisSettings { + fn analysis_settings(&self, _file: File) -> &AnalysisSettings { &self.analysis_settings } } diff --git a/crates/ty_test/src/db.rs b/crates/ty_test/src/db.rs index 9cf1c64a01..71e53677aa 100644 --- a/crates/ty_test/src/db.rs +++ b/crates/ty_test/src/db.rs @@ -134,7 +134,7 @@ impl SemanticDb for Db { false } - fn analysis_settings(&self) -> &AnalysisSettings { + fn analysis_settings(&self, _file: File) -> &AnalysisSettings { self.settings().analysis(self) } } diff --git a/fuzz/fuzz_targets/ty_check_invalid_syntax.rs b/fuzz/fuzz_targets/ty_check_invalid_syntax.rs index a73f397646..6c0e7f5ec8 100644 --- a/fuzz/fuzz_targets/ty_check_invalid_syntax.rs +++ b/fuzz/fuzz_targets/ty_check_invalid_syntax.rs @@ -100,7 +100,7 @@ impl SemanticDb for TestDb { &self.rule_selection } - fn analysis_settings(&self) -> &AnalysisSettings { + fn analysis_settings(&self, _file: File) -> &AnalysisSettings { &self.analysis_settings } diff --git a/ty.schema.json b/ty.schema.json index e1ee6d71cd..e3333c6480 100644 --- a/ty.schema.json +++ b/ty.schema.json @@ -208,6 +208,16 @@ "OverrideOptions": { "type": "object", "properties": { + "analysis": { + "anyOf": [ + { + "$ref": "#/definitions/AnalysisOptions" + }, + { + "type": "null" + } + ] + }, "exclude": { "description": "A list of file and directory patterns to exclude from this override.\n\nPatterns follow a syntax similar to `.gitignore`.\nExclude patterns take precedence over include patterns within the same override.\n\nIf not specified, defaults to `[]` (excludes no files).", "anyOf": [