From 3923c7c6926e96af570e80aeed1d961e98959bf1 Mon Sep 17 00:00:00 2001 From: Neil Schemenauer Date: Mon, 29 Jun 2026 19:29:09 -0700 Subject: [PATCH] Allow whole file error suppressions for specific kinds. This allows the following comments at the beginning of files: # pyrefly: ignore-errors[kind, ...] Multiple kinds can be comma-separated on one line, or split across several `ignore-errors` lines; the lists accumulate. Assisted-by: Claude Opus 4.8 --- crates/pyrefly_python/src/ignore.rs | 146 +++++++++++++++++++++++----- pyrefly/lib/error/collector.rs | 20 ++-- pyrefly/lib/state/errors.rs | 3 +- pyrefly/lib/test/suppression.rs | 31 ++++++ website/docs/error-suppressions.mdx | 10 ++ 5 files changed, 181 insertions(+), 29 deletions(-) diff --git a/crates/pyrefly_python/src/ignore.rs b/crates/pyrefly_python/src/ignore.rs index 25b5683db6..f2eb8d9fff 100644 --- a/crates/pyrefly_python/src/ignore.rs +++ b/crates/pyrefly_python/src/ignore.rs @@ -459,11 +459,28 @@ fn is_in_multiline_string( } } +/// A whole-file ignore directive parsed from the file's preamble. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct IgnoreAll { + /// The line where the directive for this tool first appears. + pub line: LineNumber, + /// The error kinds the directive suppresses. An empty list means *all* + /// errors. Non-empty lists only ever occur for `Tool::Pyrefly` (via + /// `# pyrefly: ignore-errors[kind, ...]`); the parser never records kinds + /// for other tools, so consumers can treat an empty list as "suppress all". + pub kinds: Vec, +} + /// Parse top-level `ignore-errors` / `ignore-all-errors` / `type: ignore` directives. /// /// Scans the beginning of the file for comment-only lines (including blank lines /// and lines inside multiline strings like docstrings). Returns the map of tools -/// with ignore-all directives and the line number where each directive appears. +/// with ignore-all directives, where each directive records its line and the +/// error kinds it suppresses (empty = all errors). +/// +/// `# pyrefly: ignore-errors[kind, ...]` suppresses only the listed kinds for the +/// whole file; multiple such lines accumulate. A bare `# pyrefly: ignore-errors` +/// (or any other tool's directive) suppresses everything and overrides any kinds. /// /// After a docstring, only `ignore-errors` directives are recognized — bare /// `# type: ignore` is not, since it could plausibly be meant as a per-line @@ -471,7 +488,27 @@ fn is_in_multiline_string( pub fn parse_ignore_all( code: &str, multiline_string_ranges: &[(LineNumber, LineNumber)], -) -> SmallMap { +) -> SmallMap { + // Record an ignore-all directive, merging with any earlier one for the same + // tool: empty `kinds` (suppress all) wins over specific kinds, and multiple + // specific-kind directives union together. The earliest line is kept. + fn record( + res: &mut SmallMap, + tool: Tool, + line: LineNumber, + kinds: Vec, + ) { + if let Some(existing) = res.get_mut(&tool) { + if kinds.is_empty() { + existing.kinds.clear(); + } else if !existing.kinds.is_empty() { + existing.kinds.extend(kinds); + } + } else { + res.insert(tool, IgnoreAll { line, kinds }); + } + } + let mut res = SmallMap::new(); let mut prev_ignore = None; let mut seen_docstring = false; @@ -504,7 +541,7 @@ pub fn parse_ignore_all( if let Some((tool, prev_line)) = prev_ignore { // The previous `# type: ignore` was followed by another comment or // blank line, so it is a whole-file suppression. - res.entry(tool).or_insert(prev_line); + record(&mut res, tool, prev_line, Vec::new()); prev_ignore = None; } @@ -514,11 +551,25 @@ pub fn parse_ignore_all( } lex.trim_start(); if lex.starts_with("pyre-ignore-all-errors") { - res.entry(Tool::Pyre).or_insert(line); + record(&mut res, Tool::Pyre, line, Vec::new()); } else if let Some(tool) = lex.starts_with_tool() { lex.trim_start(); - if lex.starts_with("ignore-errors") && lex.blank() { - res.entry(tool).or_insert(line); + if lex.starts_with("ignore-errors") { + lex.trim_start(); + if lex.blank() { + // Bare `ignore-errors`: suppress every error for this tool. + record(&mut res, tool, line, Vec::new()); + } else if tool == Tool::Pyrefly + && lex.starts_with("[") + && let Some((inside, after)) = lex.rest().split_once(']') + && after.trim().is_empty() + { + // `# pyrefly: ignore-errors[kind, ...]`: suppress only the + // listed kinds. Other tools don't support kind lists, so the + // directive is ignored for them (matching prior behavior). + let kinds = inside.split(',').map(|x| x.trim().to_owned()).collect(); + record(&mut res, tool, line, kinds); + } } else if !seen_docstring && lex.starts_with("ignore") && lex.blank() { // After a docstring, bare `# type: ignore` is not recognized // as an ignore-all directive. @@ -700,14 +751,12 @@ x = """ #[test] fn test_parse_ignore_all() { fn f(x: &str, ignores: &[(Tool, u32)]) { - assert_eq!( - parse_ignore_all(x, &[]), - ignores - .iter() - .map(|x| (x.0, LineNumber::new(x.1).unwrap())) - .collect(), - "{x:?}" - ); + let got: Vec<(Tool, u32)> = parse_ignore_all(x, &[]) + .into_iter() + .map(|(tool, ig)| (tool, ig.line.get())) + .collect(); + let want: Vec<(Tool, u32)> = ignores.to_vec(); + assert_eq!(got, want, "{x:?}"); } f("# pyrefly: ignore-errors\nx = 5", &[(Tool::Pyrefly, 1)]); @@ -738,17 +787,70 @@ x = """ f("# pyrefly: ignore-errors \nx = 5", &[(Tool::Pyrefly, 1)]); } + #[test] + fn test_parse_ignore_all_with_codes() { + fn f(x: &str, ignores: &[(Tool, u32, &[&str])]) { + let got: Vec<(Tool, u32, Vec)> = parse_ignore_all(x, &[]) + .into_iter() + .map(|(tool, ig)| (tool, ig.line.get(), ig.kinds)) + .collect(); + let want: Vec<(Tool, u32, Vec)> = ignores + .iter() + .map(|(tool, line, kinds)| { + ( + *tool, + *line, + kinds.iter().map(|k| (*k).to_owned()).collect(), + ) + }) + .collect(); + assert_eq!(got, want, "{x:?}"); + } + + // A single kind suppresses just that kind for the whole file. + f( + "# pyrefly: ignore-errors[bad-return]\nx = 5", + &[(Tool::Pyrefly, 1, &["bad-return"])], + ); + // Comma-separated kinds on one line. + f( + "# pyrefly: ignore-errors[implicit-any-parameter, implicit-any-type-argument]\nx = 5", + &[( + Tool::Pyrefly, + 1, + &["implicit-any-parameter", "implicit-any-type-argument"], + )], + ); + // One kind per line accumulates into a single directive. + f( + "# pyrefly: ignore-errors[a]\n# pyrefly: ignore-errors[b]\nx = 5", + &[(Tool::Pyrefly, 1, &["a", "b"])], + ); + // A bare `ignore-errors` overrides kinds: suppress everything. + f( + "# pyrefly: ignore-errors[a]\n# pyrefly: ignore-errors\nx = 5", + &[(Tool::Pyrefly, 1, &[])], + ); + f( + "# pyrefly: ignore-errors\n# pyrefly: ignore-errors[a]\nx = 5", + &[(Tool::Pyrefly, 1, &[])], + ); + // Only pyrefly honors kind lists; for other tools the bracketed form is + // not recognized as a directive at all. + f("# mypy: ignore-errors[a]\nx = 5", &[]); + // Trailing junk after the bracket invalidates the directive. + f("# pyrefly: ignore-errors[a] because\nx = 5", &[]); + } + #[test] fn test_parse_ignore_all_with_docstring() { fn f(x: &str, ranges: &[(LineNumber, LineNumber)], ignores: &[(Tool, u32)]) { - assert_eq!( - parse_ignore_all(x, ranges), - ignores - .iter() - .map(|x| (x.0, LineNumber::new(x.1).unwrap())) - .collect(), - "{x:?}" - ); + let got: Vec<(Tool, u32)> = parse_ignore_all(x, ranges) + .into_iter() + .map(|(tool, ig)| (tool, ig.line.get())) + .collect(); + let want: Vec<(Tool, u32)> = ignores.to_vec(); + assert_eq!(got, want, "{x:?}"); } // ignore-errors after a docstring should work diff --git a/pyrefly/lib/error/collector.rs b/pyrefly/lib/error/collector.rs index 6f6c2375ca..c79c2c1865 100644 --- a/pyrefly/lib/error/collector.rs +++ b/pyrefly/lib/error/collector.rs @@ -10,6 +10,7 @@ use std::mem; use dupe::Dupe; use pyrefly_config::error_kind::ErrorKind; +use pyrefly_python::ignore::IgnoreAll; use pyrefly_python::ignore::Tool; use pyrefly_util::lined_buffer::LineNumber; use pyrefly_util::lock::Mutex; @@ -219,16 +220,23 @@ impl ErrorCollector { fn is_error_suppressed( err: &Error, fstring_ranges: &[(LineNumber, LineNumber)], - ignore_all: &SmallMap, + ignore_all: &SmallMap, error_config: &ErrorConfig, ) -> bool { // Check whole-file ignore-all directives first. // UnusedIgnore errors cannot be suppressed to prevent infinite loops. + // An `IgnoreAll` with empty `kinds` suppresses everything; otherwise it + // suppresses only the listed kinds (or any parent kind they map to). if err.error_kind() != ErrorKind::UnusedIgnore - && error_config - .enabled_ignores - .iter() - .any(|tool| ignore_all.contains_key(tool)) + && error_config.enabled_ignores.iter().any(|tool| { + ignore_all.get(tool).is_some_and(|ig| { + ig.kinds.is_empty() + || err + .error_kind() + .suppression_names() + .any(|name| ig.kinds.iter().any(|k| k == name)) + }) + }) { return true; } @@ -258,7 +266,7 @@ impl ErrorCollector { &self, error_config: &ErrorConfig, fstring_ranges: &[(LineNumber, LineNumber)], - ignore_all: &SmallMap, + ignore_all: &SmallMap, result: &mut CollectedErrors, ) { let mut errors = self.errors.lock(); diff --git a/pyrefly/lib/state/errors.rs b/pyrefly/lib/state/errors.rs index 31583272a2..3a1d7c0f23 100644 --- a/pyrefly/lib/state/errors.rs +++ b/pyrefly/lib/state/errors.rs @@ -12,6 +12,7 @@ use dupe::Dupe; use pyrefly_config::error_kind::ErrorKind; use pyrefly_config::error_kind::Severity; use pyrefly_python::ignore::Ignore; +use pyrefly_python::ignore::IgnoreAll; use pyrefly_python::ignore::Tool; use pyrefly_python::ignore::find_comment_start_in_line; use pyrefly_python::ignore::parse_ignore_all; @@ -233,7 +234,7 @@ pub struct ModuleRanges { /// Multi-line string and backslash-continuation ranges. pub multi_line: Vec<(LineNumber, LineNumber)>, /// Top-level ignore-all directives (e.g. `# pyrefly: ignore-errors`). - pub ignore_all: SmallMap, + pub ignore_all: SmallMap, } impl ModuleRanges { diff --git a/pyrefly/lib/test/suppression.rs b/pyrefly/lib/test/suppression.rs index 6dcea4ef4b..60783fc252 100644 --- a/pyrefly/lib/test/suppression.rs +++ b/pyrefly/lib/test/suppression.rs @@ -48,6 +48,37 @@ testcase!( "#, ); +testcase!( + test_pyrefly_top_level_ignore_specific_kind, + r#" +# pyrefly: ignore-errors[bad-assignment] +x: int = "oops" +def f() -> int: + return "oops" # E: Returned type `Literal['oops']` is not assignable to declared return type `int` +"#, +); + +testcase!( + test_pyrefly_top_level_ignore_specific_kinds_one_line, + r#" +# pyrefly: ignore-errors[bad-assignment, bad-return] +x: int = "oops" +def f() -> int: + return "oops" +"#, +); + +testcase!( + test_pyrefly_top_level_ignore_specific_kinds_multiline, + r#" +# pyrefly: ignore-errors[bad-assignment] +# pyrefly: ignore-errors[bad-return] +x: int = "oops" +def f() -> int: + return "oops" +"#, +); + testcase!( test_pyrefly_top_level_ignore_wrong_same_line, r#" diff --git a/website/docs/error-suppressions.mdx b/website/docs/error-suppressions.mdx index 9cb459c2d5..123c8b8b3c 100644 --- a/website/docs/error-suppressions.mdx +++ b/website/docs/error-suppressions.mdx @@ -68,6 +68,16 @@ def bar() -> int: /> +To suppress only specific error kinds for the whole file, list them in +brackets. Multiple kinds can be comma-separated on one line, or split across +several `ignore-errors` lines; the lists accumulate. This only applies to +`pyrefly: ignore-errors` (other tools support only the bare, suppress-all form). + +```python +# pyrefly: ignore-errors[implicit-any-parameter, implicit-any-type-argument] +# pyrefly: ignore-errors[implicit-any-empty-container] +``` + Pyrefly can automatically suppress all type errors in your project by running: ```