-
Notifications
You must be signed in to change notification settings - Fork 1.6k
[ruff] Treat panics as fatal diagnostics, sort panics last #20258
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
27ab7ae to
568f553
Compare
271d6af to
abf6b7c
Compare
|
ntBre
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks!
I still think it could make sense to try to follow ty's diagnostic model a bit more closely, as I mentioned on #20252. Did you have any luck trying to add a test showing the message? That might be a nice way to play around with different message formats. Let me know if you want me to take a look, it sounded like it might be trickier than I expected in #20252 (comment).
I also don't think we can land this without auditing our usage of Diagnostic::expect_range more fully. I took another look at its references just now, and I think this call chain would cause a panic in diagnostic rendering:
ruff/crates/ruff_linter/src/message/github.rs
Lines 22 to 24 in 16d674e
| let source_location = diagnostic.expect_ruff_start_location(); | |
| let filename = diagnostic.expect_ruff_filename(); | |
| let location = if context.is_notebook(&filename) { |
ruff/crates/ruff_db/src/diagnostic/mod.rs
Line 459 in 16d674e
| pub fn expect_ruff_start_location(&self) -> LineColumn { |
If we try to render a new panic diagnostic with the GitHub output format, it will call Diagnostic::expect_ruff_start_location, which calls Diagnostic::expect_range.
Feel free to spin this off into a separate PR, but I'd feel better introducing diagnostics without ranges if we can remove Diagnostic::expect_range entirely first.
I tested this out locally to make sure I wasn't off base:
> ./target/debug/ruff check --no-cache tmp/foo.py
panic: Panicked while linting tmp/foo.py:
panicked at crates/ruff_linter/src/rules/pyflakes/rules/unused_import.rs:430:17:
brent's panic
run with `RUST_BACKTRACE=1` environment variable to display a backtrace
--> tmp/foo.py:1:1
|
|
Found 1 error.
error: Panic during linting indicates a bug in Ruff. If you could open an issue at:
https://github.com/astral-sh/ruff/issues/new?title=%5BLinter%20panic%5D
...with the relevant file contents, the `pyproject.toml` settings, and the stack trace above, we'd be very appreciative!
> ./target/debug/ruff check --no-cache tmp/foo.py --output-format github
error: Ruff crashed. If you could open an issue at:
https://github.com/astral-sh/ruff/issues/new?title=%5BPanic%5D
...quoting the executed command, along with the relevant file contents and `pyproject.toml` settings, we'd be very appreciative!
thread 'main' panicked at crates/ruff_db/src/diagnostic/mod.rs:499:22:
Expected a range for the primary span
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace568f553 to
55d36c3
Compare
abf6b7c to
d40e9c6
Compare
|
Check-pointed where I got to with trying to get a test rule — will look at teasing out the |
d40e9c6 to
ef147de
Compare
- Convert panics to diagnostics with id `Panic`, severity `Fatal`, and the error as the diagnostic message, annotated with a `Span` with empty code block and no range. - Updates the post-linting message diagnostic handling to track the maximum severity seen, and then prints the "report a bug in ruff" message only if the max severity was `Fatal` - Updates the diagnostic sorting to sort panics to the bottom - Adds a new test rule and test case to exercise panics in lint rules This depends on the sorting changes since it creates diagnostics with no range specified.
ef147de to
edf05f2
Compare
cc59819 to
870acc7
Compare
MichaReiser
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This looks great to me but I'll defer to @ntBre to do the final review as he's more up to date with what you discussed
crates/ruff/tests/lint.rs
Outdated
| [TMP]/panic.py: panic: Panicked while linting [TMP]/panic.py: | ||
| panicked at crates/ruff_linter/src/rules/ruff/rules/test_rules.rs:511:9: | ||
| This is a fake panic for testing. | ||
| run with `RUST_BACKTRACE=1` environment variable to display a backtrace | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We try to avoid multiline content in diagnostic messages because it can break rendering for some output formats (as seen here).
You can see how we accomplish this in ty here:
ruff/crates/ty_project/src/lib.rs
Lines 669 to 746 in b3cc733
| use std::fmt::Write; | |
| let mut message = String::new(); | |
| message.push_str("Panicked"); | |
| if let Some(location) = error.location { | |
| let _ = write!(&mut message, " at {location}"); | |
| } | |
| let _ = write!( | |
| &mut message, | |
| " when checking `{file}`", | |
| file = file.path(db) | |
| ); | |
| if let Some(payload) = error.payload.as_str() { | |
| let _ = write!(&mut message, ": `{payload}`"); | |
| } | |
| let mut diagnostic = Diagnostic::new(DiagnosticId::Panic, Severity::Fatal, message); | |
| diagnostic.sub(SubDiagnostic::new( | |
| SubDiagnosticSeverity::Info, | |
| "This indicates a bug in ty.", | |
| )); | |
| let report_message = "If you could open an issue at https://github.com/astral-sh/ty/issues/new?title=%5Bpanic%5D, we'd be very appreciative!"; | |
| diagnostic.sub(SubDiagnostic::new( | |
| SubDiagnosticSeverity::Info, | |
| report_message, | |
| )); | |
| diagnostic.sub(SubDiagnostic::new( | |
| SubDiagnosticSeverity::Info, | |
| format!( | |
| "Platform: {os} {arch}", | |
| os = std::env::consts::OS, | |
| arch = std::env::consts::ARCH | |
| ), | |
| )); | |
| if let Some(version) = ruff_db::program_version() { | |
| diagnostic.sub(SubDiagnostic::new( | |
| SubDiagnosticSeverity::Info, | |
| format!("Version: {version}"), | |
| )); | |
| } | |
| diagnostic.sub(SubDiagnostic::new( | |
| SubDiagnosticSeverity::Info, | |
| format!( | |
| "Args: {args:?}", | |
| args = std::env::args().collect::<Vec<_>>() | |
| ), | |
| )); | |
| if let Some(backtrace) = error.backtrace { | |
| match backtrace.status() { | |
| BacktraceStatus::Disabled => { | |
| diagnostic.sub(SubDiagnostic::new( | |
| SubDiagnosticSeverity::Info, | |
| "run with `RUST_BACKTRACE=1` environment variable to show the full backtrace information", | |
| )); | |
| } | |
| BacktraceStatus::Captured => { | |
| diagnostic.sub(SubDiagnostic::new( | |
| SubDiagnosticSeverity::Info, | |
| format!("Backtrace:\n{backtrace}"), | |
| )); | |
| } | |
| _ => {} | |
| } | |
| } | |
| if let Some(backtrace) = error.salsa_backtrace { | |
| salsa::attach(db, || { | |
| diagnostic.sub(SubDiagnostic::new( | |
| SubDiagnosticSeverity::Info, | |
| backtrace.to_string(), | |
| )); | |
| }); | |
| } |
The downside of ty's approach is that we only show the "Please open an issue" part if a user uses output-format="full" (or any other format that renders sub diagnostics).
I don't feel strongly about this but I thought it was worth mentioning in case this wasn't an intentional design decision.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The PR looks great to me overall too! I think this is the main thing I'd find interesting to try out (plus the full severity sort). It looks like we can also clean up the message a bit since the diagnostic infrastructure already adds the filename automatically. Something like:
[TMP]/panic.py: panic: Panicked while linting
instead of the current
[TMP]/panic.py: panic: Panicked while linting [TMP]/panic.py:
panic: Panicked also seems a bit redundant, but I think panic: while linting would also be awkward.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Moved the full error/backtrace into a subdiagnostic, and made the main diagnostic message a single line like fatal error while linting: <panic summary>. I've left the "please open an issue" as a separate log to stderr, so that it would appear even in output formats like concise, but presumably shouldn't affect ability to process output on stdout.
The alternate is moving that message into a second subdiagnostic, but then it would be repeated for every single panic rather than just once at the end, and would also end up being hidden in concise output (or we make the main message more verbose? 😐).
As-is, in concise and full output format, it now looks like:
amethyst@lunatone ~/workspace/ruff amy/panic-diagnostics+ » cargo run -p ruff -- check crates/ruff_linter/resources/test/fixtures/flake8_async/ASYNC25*.py --no-cache --preview --select ASYNC --output-format=concise
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.10s
Running `target/debug/ruff check crates/ruff_linter/resources/test/fixtures/flake8_async/ASYNC250.py crates/ruff_linter/resources/test/fixtures/flake8_async/ASYNC251.py --no-cache --preview --select ASYNC --output-format=concise`
crates/ruff_linter/resources/test/fixtures/flake8_async/ASYNC251.py:6:5: ASYNC251 Async functions should not call `time.sleep`
crates/ruff_linter/resources/test/fixtures/flake8_async/ASYNC250.py: panic: Fatal error while linting: fake panic
Found 2 errors.
error: Panic during linting indicates a bug in Ruff. If you could open an issue at:
https://github.com/astral-sh/ruff/issues/new?title=%5BLinter%20panic%5D
...with the relevant file contents, the `pyproject.toml` settings, and the stack trace above, we'd be very appreciative!
> [2]
amethyst@lunatone ~/workspace/ruff amy/panic-diagnostics+ » cargo run -p ruff -- check crates/ruff_linter/resources/test/fixtures/flake8_async/ASYNC25*.py --no-cache --preview --select ASYNC --output-format=full
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.10s
Running `target/debug/ruff check crates/ruff_linter/resources/test/fixtures/flake8_async/ASYNC250.py crates/ruff_linter/resources/test/fixtures/flake8_async/ASYNC251.py --no-cache --preview --select ASYNC --output-format=full`
ASYNC251 Async functions should not call `time.sleep`
--> crates/ruff_linter/resources/test/fixtures/flake8_async/ASYNC251.py:6:5
|
5 | async def func():
6 | time.sleep(1) # ASYNC251
| ^^^^^^^^^^
|
panic: Fatal error while linting: fake panic
--> crates/ruff_linter/resources/test/fixtures/flake8_async/ASYNC250.py:1:1
|
|
info: panicked at crates/ruff_linter/src/rules/flake8_async/rules/blocking_input.rs:50:13:
fake panic
run with `RUST_BACKTRACE=1` environment variable to display a backtrace
Found 2 errors.
error: Panic during linting indicates a bug in Ruff. If you could open an issue at:
https://github.com/astral-sh/ruff/issues/new?title=%5BLinter%20panic%5D
...with the relevant file contents, the `pyproject.toml` settings, and the stack trace above, we'd be very appreciative!
> [2]
| error: Panic during linting indicates a bug in Ruff. If you could open an issue at: | ||
| https://github.com/astral-sh/ruff/issues/new?title=%5BLinter%20panic%5D | ||
| ...with the relevant file contents, the `pyproject.toml` settings, and the stack trace above, we'd be very appreciative! | ||
| "); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think I sort of prefer the old rendering where this message was next to the panic-diagnostic. As a user, it's now less clear to me what I have to report (imagine a few 100 diagnostics).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Part of the change was making sure that panics always sort to the bottom of the diagnostics, so other than being on stderr, to an interactive user it should always render "next to" the actual panic messages, which should always render after all of the non-fatal diagnostics.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oh I see, it might be worth adding an inline comment explaining that this is the intended behavior as it wasn't obvious to me.
| /// Panics if either diagnostic has no primary span, or if its file is not a `SourceFile`. | ||
| pub fn ruff_start_ordering(&self, other: &Self) -> std::cmp::Ordering { | ||
| let a = ( | ||
| self.severity().is_fatal(), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We could consider comparing by severity here (in reverse order)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
As a user I still prefer having them sorted by file/lineno, because that lets me visually go through the list without jumping back and forth between different files or different parts of a single file. Eg, all of the lints for a single function or class would always be together in the final output.
But because panics trigger Ruff to discard all other diagnostics for a file, there's currently nothing for them to be grouped with, so I think it's reasonable to sort those at the end so that it's more obvious to the user.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
As a user I still prefer having them sorted by file/lineno, because that lets me visually go through the list without jumping back and forth between different files or different parts of a single file. Eg, all of the lints for a single function or class would always be together in the final output.
That makes sense and is consistent with what we have in ty:
ruff/crates/ruff_db/src/diagnostic/mod.rs
Lines 534 to 565 in b6a740f
| // We sort diagnostics in a way that keeps them in source order | |
| // and grouped by file. After that, we fall back to severity | |
| // (with fatal messages sorting before info messages) and then | |
| // finally the diagnostic ID. | |
| fn cmp(&self, other: &Self) -> std::cmp::Ordering { | |
| if let (Some(span1), Some(span2)) = ( | |
| self.diagnostic.primary_span(), | |
| other.diagnostic.primary_span(), | |
| ) { | |
| let order = span1.file().path(&self.db).cmp(span2.file().path(&self.db)); | |
| if order.is_ne() { | |
| return order; | |
| } | |
| if let (Some(range1), Some(range2)) = (span1.range(), span2.range()) { | |
| let order = range1.start().cmp(&range2.start()); | |
| if order.is_ne() { | |
| return order; | |
| } | |
| } | |
| } | |
| // Reverse so that, e.g., Fatal sorts before Info. | |
| let order = self | |
| .diagnostic | |
| .severity() | |
| .cmp(&other.diagnostic.severity()) | |
| .reverse(); | |
| if order.is_ne() { | |
| return order; | |
| } | |
| self.diagnostic.id().cmp(&other.diagnostic.id()) | |
| } |
The only difference is that ty only compares by severity if the file doesn't have a range and fatal errors come first rather than last and ty prints an extra warning as part of the summary if there were any panics (to avoid users missing them).
I don't have a strong preference towards either approach and using is_fatal here seems fine after reading your reasoning.
| /// bar | ||
| /// ``` | ||
| #[derive(ViolationMetadata)] | ||
| pub(crate) struct PanicyTestRule; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nice way of testing this (we don't have this in ty)
870acc7 to
df9f66e
Compare
MichaReiser
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This looks good to me
ntBre
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looks good to me too! Just one small suggestion about the diagnostic
crates/ruff/src/commands/check.rs
Outdated
|
|
||
| Ok(Diagnostics::default()) | ||
| let span = Span::from(SourceFileBuilder::new(path.to_string_lossy(), "").finish()); | ||
| diagnostic.annotate(Annotation::primary(span)); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think we might want to call set_file_level(true) on this Annotation to avoid the empty snippet (| lines) in the diagnostic from your example:
--> crates/ruff_linter/resources/test/fixtures/flake8_async/ASYNC250.py:1:1
|
|
info: panicked at crates/ruff_linter/src/rules/flake8_async/rules/blocking_input.rs:50:13:
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If you're interested in removing the need for set_file_level, this is listed as a potential improvement in your welcome document:
This task is related to the one above. Ruff currently uses an empty text range at position 0 (start of the file) for diagnostics that apply to the entire file. We need the range for suppression comments to work, but we don’t want to render a code frame for those. Brent added a flag for annotations (the diagnostic system renders code snippets for each annotation) so that they can be marked as file-level, in which case the code snippets aren’t rendered....
e704155 to
e7b39c3
Compare
Panic, severityFatal, and the error as the diagnostic message, annotated with aSpanwith empty code block and no range.FatalThis depends on the sorting changes since it creates diagnostics with no range specified.