-
Notifications
You must be signed in to change notification settings - Fork 1.6k
[ty] Don't suggest things that aren't subclasses of BaseException after raise
#21571
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
Merged
+102
−17
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -9,9 +9,10 @@ use ruff_python_ast::name::Name; | |
| use ruff_python_codegen::Stylist; | ||
| use ruff_python_parser::{Token, TokenAt, TokenKind, Tokens}; | ||
| use ruff_text_size::{Ranged, TextLen, TextRange, TextSize}; | ||
| use ty_python_semantic::types::UnionType; | ||
| use ty_python_semantic::{ | ||
| Completion as SemanticCompletion, ModuleName, NameKind, SemanticModel, | ||
| types::{CycleDetector, Type}, | ||
| Completion as SemanticCompletion, KnownModule, ModuleName, NameKind, SemanticModel, | ||
| types::{CycleDetector, KnownClass, Type}, | ||
| }; | ||
|
|
||
| use crate::docstring::Docstring; | ||
|
|
@@ -82,6 +83,31 @@ impl<'db> Completions<'db> { | |
| fn force_add(&mut self, completion: Completion<'db>) { | ||
| self.items.push(completion); | ||
| } | ||
|
|
||
| /// Tags completions with whether they are known to be usable in | ||
| /// a `raise` context. | ||
| /// | ||
| /// It's possible that some completions are usable in a `raise` | ||
| /// but aren't marked by this method. That is, false negatives are | ||
| /// possible but false positives are not. | ||
| fn tag_raisable(&mut self) { | ||
| let raisable_type = UnionType::from_elements( | ||
| self.db, | ||
| [ | ||
| KnownClass::BaseException.to_subclass_of(self.db), | ||
| KnownClass::BaseException.to_instance(self.db), | ||
| ], | ||
| ); | ||
| for item in &mut self.items { | ||
| let Some(ty) = item.ty else { continue }; | ||
| item.is_definitively_raisable = ty.is_assignable_to(self.db, raisable_type); | ||
| } | ||
| } | ||
|
|
||
| /// Removes any completion that doesn't satisfy the given predicate. | ||
| fn retain(&mut self, predicate: impl FnMut(&Completion<'_>) -> bool) { | ||
| self.items.retain(predicate); | ||
| } | ||
| } | ||
|
|
||
| impl<'db> Extend<SemanticCompletion<'db>> for Completions<'db> { | ||
|
|
@@ -153,6 +179,13 @@ pub struct Completion<'db> { | |
| /// Whether this item only exists for type checking purposes and | ||
| /// will be missing at runtime | ||
| pub is_type_check_only: bool, | ||
| /// Whether this item can definitively be used in a `raise` context. | ||
| /// | ||
| /// Note that this may not always be computed. (i.e., Only computed | ||
| /// when we are in a `raise` context.) And also note that if this | ||
| /// is `true`, then it's definitively usable in `raise`, but if | ||
| /// it's `false`, it _may_ still be usable in `raise`. | ||
| pub is_definitively_raisable: bool, | ||
| /// The documentation associated with this item, if | ||
| /// available. | ||
| pub documentation: Option<Docstring>, | ||
|
|
@@ -177,6 +210,7 @@ impl<'db> Completion<'db> { | |
| import: None, | ||
| builtin: semantic.builtin, | ||
| is_type_check_only, | ||
| is_definitively_raisable: false, | ||
| documentation, | ||
| } | ||
| } | ||
|
|
@@ -257,6 +291,7 @@ impl<'db> Completion<'db> { | |
| import: None, | ||
| builtin: false, | ||
| is_type_check_only: false, | ||
| is_definitively_raisable: false, | ||
| documentation: None, | ||
| } | ||
| } | ||
|
|
@@ -271,6 +306,7 @@ impl<'db> Completion<'db> { | |
| import: None, | ||
| builtin: true, | ||
| is_type_check_only: false, | ||
| is_definitively_raisable: false, | ||
| documentation: None, | ||
| } | ||
| } | ||
|
|
@@ -364,6 +400,20 @@ pub fn completion<'db>( | |
| } | ||
| } | ||
|
|
||
| if is_raising_exception(tokens) { | ||
| completions.tag_raisable(); | ||
|
|
||
| // As a special case, and because it's a common footgun, we | ||
| // specifically disallow `NotImplemented` in this context. | ||
| // `NotImplementedError` should be used instead. So if we can | ||
| // definitively detect `NotImplemented`, then we can safely | ||
| // omit it from suggestions. | ||
| completions.retain(|item| { | ||
| let Some(ty) = item.ty else { return true }; | ||
| !ty.is_notimplemented(db) | ||
| }); | ||
| } | ||
|
|
||
| completions.into_completions() | ||
| } | ||
|
|
||
|
|
@@ -427,7 +477,8 @@ fn add_unimported_completions<'db>( | |
| let members = importer.members_in_scope_at(scoped.node, scoped.node.start()); | ||
|
|
||
| for symbol in all_symbols(db, &completions.query) { | ||
| if symbol.module.file(db) == Some(file) { | ||
| if symbol.module.file(db) == Some(file) || symbol.module.is_known(db, KnownModule::Builtins) | ||
| { | ||
| continue; | ||
| } | ||
|
|
||
|
|
@@ -450,6 +501,7 @@ fn add_unimported_completions<'db>( | |
| builtin: false, | ||
| // TODO: `is_type_check_only` requires inferring the type of the symbol | ||
| is_type_check_only: false, | ||
| is_definitively_raisable: false, | ||
| documentation: None, | ||
| }); | ||
| } | ||
|
|
@@ -1358,6 +1410,30 @@ fn is_in_variable_binding(parsed: &ParsedModuleRef, offset: TextSize, typed: Opt | |
| }) | ||
| } | ||
|
|
||
| /// Returns true when the cursor is after a `raise` keyword. | ||
| fn is_raising_exception(tokens: &[Token]) -> bool { | ||
| /// The maximum number of tokens we're willing to | ||
| /// look-behind to find a `raise` keyword. | ||
| const LIMIT: usize = 10; | ||
|
|
||
| // This only looks for things like `raise foo.bar.baz.qu<CURSOR>`. | ||
| // Technically, any kind of expression is allowed after `raise`. | ||
| // But we may not always want to treat it specially. So we're | ||
| // rather conservative about what we consider "raising an | ||
| // exception" to be for the purposes of completions. The failure | ||
| // mode here is that we may wind up suggesting things that | ||
| // shouldn't be raised. The benefit is that when this heuristic | ||
| // does work, we won't suggest things that shouldn't be raised. | ||
| for token in tokens.iter().rev().take(LIMIT) { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think you want to, at least, skip all trivia tokens and parentheses ( raise (
a # comment
.Error
) |
||
| match token.kind() { | ||
| TokenKind::Name | TokenKind::Dot => continue, | ||
| TokenKind::Raise => return true, | ||
| _ => return false, | ||
| } | ||
| } | ||
| false | ||
| } | ||
|
|
||
| /// Order completions according to the following rules: | ||
| /// | ||
| /// 1) Names with no underscore prefix | ||
|
|
@@ -1370,8 +1446,16 @@ fn is_in_variable_binding(parsed: &ParsedModuleRef, offset: TextSize, typed: Opt | |
| /// This has the effect of putting all dunder attributes after "normal" | ||
| /// attributes, and all single-underscore attributes after dunder attributes. | ||
| fn compare_suggestions(c1: &Completion, c2: &Completion) -> Ordering { | ||
| fn key<'a>(completion: &'a Completion) -> (bool, bool, bool, NameKind, bool, &'a Name) { | ||
| fn key<'a>(completion: &'a Completion) -> (bool, bool, bool, bool, NameKind, bool, &'a Name) { | ||
| ( | ||
| // This is only true when we are both in a `raise` context | ||
| // *and* we know this suggestion is definitively usable | ||
| // in a `raise` context. So we should sort these before | ||
| // anything else. | ||
| !completion.is_definitively_raisable, | ||
| // When `None`, a completion is for something in the | ||
| // current module, which we should generally prefer over | ||
| // something from outside the module. | ||
| completion.module_name.is_some(), | ||
| // At time of writing (2025-11-11), keyword completions | ||
| // are classified as builtins, which makes them sort after | ||
|
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
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.
Wow I need to review more of your PRs, y'all are wildin' in here. I assume you can't more-normally walk up the AST because autocomplete has to operate in malformed ASTs more?
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 it's more that I've been operating under the presumption that "if we can do things directly from the tokens, then that's probably better." In part to avoid the problem you bring up (although I've found our AST to be pretty good at dealing with malformed input) but also because it's very cheap to just look at a few tokens. It's possible that doing an AST traversal is also cheap enough. I honestly don't have a great sense of it yet, but for completions we do a lot of "check if we're in context foo, or bar, or baz, or quux..." so the faster each of those checks are (which I assume will continue to grow over time), the better. Although I did just remove a bunch of those checks for imports and consolidated it into one single check.
TL;DR - We could probably look at the AST for cases like this, but I'm starting simple.
Uh oh!
There was an error while loading. Please reload this page.
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.
Walking tokens is certainly faster, but walking the AST also isn't crazy expensive (all Ruff lint rules just do that). That's why I'd pick whatever is easier.
The bigger challenge with our AST is that you can't walk upwards (or sideways). At least not without building another intermediate representation that stores upward pointers for each AST node
One downside of using tokens is that there are cases where the parsed AST and token stream can disagree. E.g. the parser sometimes synthesizes name expressions if a required expression is missing (
e.g. a.should synthesize a name expression for the attribute). However, we don't synthesize a name token in that case).However, our lexing is, to some extent, parser-directed during error recovery. For example, a "normal" lexer would parse the whitespace before
passas such whitespace because there's an unclosed(. This is not the case for our token stream because the parser will inform the lexer that it will start error recovery after(becausepassisn't a valid argument name and that the lexer should try to lex the current token, assuming it's in a statement context, in which case the whitespace is lexed as an indent.https://play.ruff.rs/f1d64305-c9ff-47cc-8edf-3a7216871932
However, there are a few cases where the parsed AST and the token stream can disagree, e.g. the token stream doesn't contain a newline before
passeven though this is parsed asdef test():\npasshttps://play.ruff.rs/d9e759b6-d676-4871-b421-abbd58514fc9
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 for that explanation! I didn't know some of that. I think sticking with tokens for now is fine. We can always switch to AST in response to user feedback.