[ty] suppress invalid suggestions in import statements (#21484)

<!--
Thank you for contributing to Ruff/ty! To help us out with reviewing,
please consider the following:

- Does this pull request include a summary of the change? (See below.)
- Does this pull request include a descriptive title? (Please prefix
with `[ty]` for ty pull
  requests.)
- Does this pull request include references to any relevant issues?
-->

## Summary

<!-- What's the purpose of the change? What does it do, and why? -->

Partially addresses https://github.com/astral-sh/ty/issues/1562

Only suggest the keyword "as" in import statements when the user have
written `import foo a<CURSOR>` or `from foo import bar a<CURSOR>` as no
other suggestion makes sense here.

Re-uses the existing pattern for incomplete `import from` statements to
determine incomplete import alias statements and make the suggestions
more sane in those cases.

There was a potential suggestion from @BurntSushi in
https://github.com/astral-sh/ty/issues/1562#issue-3626853513 to move the
handling of import statements into one unified state machine but I acted
on the side of caution and fixed this with already established patterns,
pending a potential bigger re-write down the line.

## Test Plan

Added new tests and checked that it behaved reasonable in the
playground.

<!-- How was it tested? -->
This commit is contained in:
RasmusNygren 2025-11-17 15:58:49 +01:00 committed by GitHub
parent c16ef709f6
commit d063c71177
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -358,6 +358,9 @@ fn only_keyword_completion<'db>(tokens: &[Token], typed: Option<&str>) -> Option
if is_import_from_incomplete(tokens, typed) {
return Some(Completion::keyword("import"));
}
if is_import_alias_incomplete(tokens, typed) {
return Some(Completion::keyword("as"));
}
None
}
@ -914,6 +917,59 @@ fn is_import_from_incomplete(tokens: &[Token], typed: Option<&str>) -> bool {
false
}
/// Detects `import <name> <CURSOR>` statements with a potentially incomplete
/// `as` clause.
///
/// Note that this works for `from <module> import <name> <CURSOR>` as well.
///
/// If found, `true` is returned.
fn is_import_alias_incomplete(tokens: &[Token], typed: Option<&str>) -> bool {
use TokenKind as TK;
const LIMIT: usize = 1_000;
/// A state used to "parse" the tokens preceding the user's cursor,
/// in reverse, to detect a "import <name> as" statement.
enum S {
Start,
As,
Name,
}
if typed.is_none() {
return false;
}
let mut state = S::Start;
for token in tokens.iter().rev().take(LIMIT) {
state = match (state, token.kind()) {
(S::Start, TK::Name | TK::Unknown | TK::As) => S::As,
(S::As, TK::Name | TK::Case | TK::Match | TK::Type | TK::Unknown) => S::Name,
(
S::Name,
TK::Name
| TK::Dot
| TK::Ellipsis
| TK::Case
| TK::Match
| TK::Type
| TK::Unknown
| TK::Comma
| TK::As
| TK::Newline
| TK::NonLogicalNewline
| TK::Lpar
| TK::Rpar,
) => S::Name,
// Once we reach the `import` token we know we're in
// `import name <CURSOR>`.
(S::Name, TK::Import) => return true,
_ => return false,
};
}
false
}
/// Looks for the text typed immediately before the cursor offset
/// given.
///
@ -4690,6 +4746,68 @@ from collections import defaultdict as f<CURSOR>
);
}
#[test]
fn import_missing_alias_suggests_as() {
let builder = completion_test_builder(
"\
import collections a<CURSOR>
",
);
assert_snapshot!(builder.build().snapshot(), @"as");
}
#[test]
fn import_dotted_module_missing_alias_suggests_as() {
let builder = completion_test_builder(
"\
import collections.abc a<CURSOR>
",
);
assert_snapshot!(builder.build().snapshot(), @"as");
}
#[test]
fn import_multiple_modules_missing_alias_suggests_as() {
let builder = completion_test_builder(
"\
import collections.abc as c, typing a<CURSOR>
",
);
assert_snapshot!(builder.build().snapshot(), @"as");
}
#[test]
fn from_import_missing_alias_suggests_as() {
let builder = completion_test_builder(
"\
from collections.abc import Mapping a<CURSOR>
",
);
assert_snapshot!(builder.build().snapshot(), @"as");
}
#[test]
fn from_import_parenthesized_missing_alias_suggests_as() {
let builder = completion_test_builder(
"\
from typing import (
NamedTuple a<CURSOR>
)
",
);
assert_snapshot!(builder.build().snapshot(), @"as");
}
#[test]
fn from_relative_import_missing_alias_suggests_as() {
let builder = completion_test_builder(
"\
from ...foo import bar a<CURSOR>
",
);
assert_snapshot!(builder.build().snapshot(), @"as");
}
#[test]
fn no_completions_in_with_alias() {
let builder = completion_test_builder(