Skip to content

[ty] Add imports when an unimported completion is selected#20439

Merged
BurntSushi merged 15 commits intomainfrom
ag/refactor-importer
Sep 17, 2025
Merged

[ty] Add imports when an unimported completion is selected#20439
BurntSushi merged 15 commits intomainfrom
ag/refactor-importer

Conversation

@BurntSushi
Copy link
Member

This PR does the work necessary to make the "import"
part of "auto-import" work. That is, when an unimported
completion is found, it is now returned with an LSP text
edit for inserting an import that should bring that symbol
into scope.

The diffstat here is quite large, but a bulk of that are
tests. And this PR should be reviewed commit-by-commit.
Many of the commits are somewhat smaller refactorings.
The last commit is where the new Importer abstraction is
introduced, and it's where (arguably) most of the weeds are.

For now, I've not concerned myself with performance in order
to avoid getting distracted by it. In particular, every
unimported completion gets an edit generated for it. This
strikes me as sub-optimal, and indeed, it looks like the
LSP protocol has support for running "commands" in response
to a completion being selected. Future work might involve
taking advantage of that, but it's not clear to me yet how
expensive (relatively speaking) generating these edits
actually is.

@BurntSushi
Copy link
Member Author

Demo:

even-slicker-import-demo.mp4

@github-actions
Copy link
Contributor

github-actions bot commented Sep 16, 2025

Diagnostic diff on typing conformance tests

No changes detected when running ty on typing conformance tests ✅

@github-actions
Copy link
Contributor

github-actions bot commented Sep 16, 2025

mypy_primer results

No ecosystem changes detected ✅
No memory usage changes detected ✅

@github-actions
Copy link
Contributor

github-actions bot commented Sep 16, 2025

ruff-ecosystem results

Linter (stable)

✅ ecosystem check detected no linter changes.

Linter (preview)

✅ ecosystem check detected no linter changes.

Formatter (stable)

✅ ecosystem check detected no format changes.

Formatter (preview)

✅ ecosystem check detected no format changes.

@BurntSushi BurntSushi added server Related to the LSP server ty Multi-file analysis & type inference labels Sep 16, 2025
@BurntSushi BurntSushi force-pushed the ag/refactor-importer branch 2 times, most recently from aebc152 to 8e4d1c7 Compare September 16, 2025 18:35
Copy link
Member

@MichaReiser MichaReiser left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is great!

I think it would be nice to make use of the LSPs lazy resolution of completion metadata (e.g. lazily resolve the Type of a `Completion) but I agree with your scoping that we should leave this to a separate improvement.

We probably also want some more advanced sorting of completion items to e.g. use the closest (and most high-level) re-export of a symbol over importing it from a very deeply nested submodule (or from a test!). But again, these are incremental improvements that we can make later.

Comment on lines +37 to +44
pub fn into_owned(self) -> Stylist<'static> {
Stylist {
source: Cow::Owned(self.source.into_owned()),
indentation: self.indentation,
quote: self.quote,
line_ending: self.line_ending,
}
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Neat!

But I'm not sure if this is actually needed, given that we only need into_owned in tests and only in tests that call import (or import_from) of which, I think, most tests only contain exactly one call.

I'm leaning towards creating the Stylist ad-hoc on demand. The only downside is that we compute the line_ending a few more times than necessary but that should be neglectable, given that most test files have very short lines (it only searches for the first line ending)

Copy link
Member Author

@BurntSushi BurntSushi Sep 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's a little more subtle than that. An Importer wants a &Stylist. So if we create a Stylist on-demand, it can't be tied to the stack frame that creates an Importer and returns that importer.

There are a lot of different ways around this. A Stylist could always own its source for example. Or the tests could always be required to create an Importer in the same stack frame (or above) as where it's used.

But I found this to be simple and it follows a standard API pattern in Rust libraries for creating borrowed/owned versions of the same type. And I don't think the cost here is that big.

Comment on lines +740 to +741
// if predicate:
// import whatever
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we'll want to support those for type-checking only imports. I think it's totally fine that this PR ignores those, but we should think about when we should add support for them.

Unlike Rust, importing a module has a runtime cost. Because of that, Python supports type-checking only imports, to avoid importing modules that are only needed to type some API.

The way type-checking only imports work in Python is that the imports are gated by an if TYPE_CHECKING guard (it's a well-known constant by type checkers or it can be imported from the typing module):

from typing import TYPE_CHECKING

if TYPE_CHECKING:
    import foo

Now, symbols imported in a TYPE_CHECKING block aren't available at runtime. That means, they should only be suggested when in a type position (and e.g. not in the condition of an if statement).

It's also preferred to add the imports to an if TYPE_CHECKING block if the import is only used for type checking.

I don't know how far existing LSPs go here but Ruff has a few rules to help with this: https://docs.astral.sh/ruff/rules/#flake8-type-checking-tc

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah good call out! I thought about this when working on the importer, but the "how do you know when it should go into a TYPE_CHECKING block" seemed like it was big enough to be worth punting on for now. I've added this to my notes for completions.

Copy link
Member

@AlexWaygood AlexWaygood left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks very exciting!!

The documentation you've added is (as usual) fantastic. I did spot a few typos reading through the comments, so I ended up doing a docs-only review pass (sorry if some comments here are a bit nitpicky!)

BurntSushi added a commit that referenced this pull request Sep 17, 2025
It seems like we'd like to remove `Locator` since it's a bit
awkward in how it works:
#20439 (comment)

It looked pretty easy to rip it out of the `Importer`, so that's
one less thing using it.
As I was playing around in this file, it was much nicer
to just use `cst::` everywhere, similar to what we do with
`ruff_python_ast`.
This refactors the importer abstraction to use a shared
`Insertion`. This is mostly just moving some code around
with some slight tweaks.

The plan here is to keep the rest of the importing code
in `ruff_linter` and then write something ty-specific on
top of `Insertion`. This ends up sharing some code, but
not as much as would be ideal. In particular, the
`ruff_linter` imported is pretty tightly coupled with
ruff's semantic model. So to share the code, we'd need to
abstract over that.
This makes it easier to test with in some cases and generally shouldn't
cost anything.
Basically, given a `from module import name1, name2, ...` statement,
we'd like to be able to insert another name in that list.

This new `Insertion::existing_import` API provides such
functionality. There isn't much to it, although we are careful
to try and avoid inserting nonsense for import statements
that are already invalid.
This can already be accomplished via a `From` impl (and indeed,
that's how this is implemented). But in a generic context, the
turbo-fishing that needs to be applied is quite annoying.
We're going to want to use this outside of `ty_python_semantic`.
Specifically, in `ty_ide`.
The names of the submodules returned should be *complete*. This
is the contract of `Module::name`. However, we were previously
only returning the basename of the submodule.
I think this is a better home for it. This way, `ty_ide`
more clearly owns how the "kind" of a completion is computed.
In particular, it is computed differently for things where
we know its type versus unimported symbols.
Based on how this API is currently implemented, this doesn't
really cost us anything. But it gives us access to more
information about where the symbol is defined.
This rejiggers some stuff in the main completions entrypoint
in `ty_ide`. A more refined `Completion` type is defined
with more information. In particular, to support auto-import,
we now include a module name and an "edit" for inserting an
import.

This also rolls the old "detailed completion" into the new
completion type. Previously, we were relying on the completion
type for `ty_python_semantic`. But `ty_ide` is really the code
that owns completions.

Note that this code doesn't build as-is. The next commit will
add the importer used here in `add_unimported_completions`.
This is somewhat inspired by a similar abstraction in
`ruff_linter`. The main idea is to create an importer once
for a module that you want to add imports to. And then call
`import` to generate an edit for each symbol you want to
add.

I haven't done any performance profiling here yet. I don't
know if it will be a bottleneck. In particular, I do expect
`Importer::import` (but not `Importer::new`) to get called
many times for a single completion request when auto-import
is enabled. Particularly in projects with a lot of unimported
symbols. Because I don't know the perf impact, I didn't do
any premature optimization here. But there are surely some
low hanging fruit if this does prove to be a problem.

New tests make up a big portion of the diff here. I tried to
think of a bunch of different cases, although I'm sure there
are more.
It seems like we'd like to remove `Locator` since it's a bit
awkward in how it works:
#20439 (comment)

It looked pretty easy to rip it out of the `Importer`, so that's
one less thing using it.
@BurntSushi
Copy link
Member Author

Going to bring this in, but I'm happy to address additional feedback in follow-ups. :-)

@BurntSushi BurntSushi merged commit 3fcbe8b into main Sep 17, 2025
38 checks passed
BurntSushi added a commit that referenced this pull request Sep 17, 2025
It seems like we'd like to remove `Locator` since it's a bit
awkward in how it works:
#20439 (comment)

It looked pretty easy to rip it out of the `Importer`, so that's
one less thing using it.
@BurntSushi BurntSushi deleted the ag/refactor-importer branch September 17, 2025 17:59
BurntSushi added a commit that referenced this pull request Sep 18, 2025
We don't attempt to fix these yet. I think there are bigger fish to fry.

I came up with these based on this discussion:
#20439 (comment)

Here's one example:

```
if ...:
    from foo import MAGIC
else:
    from bar import MAGIC

MAG<CURSOR>
```

Now in this example, completions will include `MAGIC` from the local
scope. That is, auto-import is involved with that completion. But at
present, auto-import will suggest importing `foo` and `bar` because we
haven't de-duplicated completions yet. Which is fine.

Here's another example:

```
if ...:
    import foo as fubar
else:
    import bar as fubar

MAG<CURSOR>
```

Now here, there is no `MAGIC` symbol in scope. So auto-import is in
play. Let's assume that the user selects `MAGIC` from `foo` in this
example. (`bar` also has `MAGIC`.)

Since we currently ignore the declaration site for symbols with
multiple possible bindings, the importer today doesn't know that
`fubar` _could_ contain `MAGIC`. But even if it did, what would we do
with that information? Should we do this?

```
if ...:
    import foo as fubar
    from foo import MAGIC
else:
    import bar as fubar

MAGIC
```

Or could we reason that `bar` also has `MAGIC`?

```
if ...:
    import foo as fubar
else:
    import bar as fubar

fubar.MAGIC
```

But if we did that, we're making an assumption of user intent, since
they *selected* `foo.MAGIC` but not `bar.MAGIC`.

Anyway, I don't think we need to settle on an answer today, but I
wanted to capture some of these tricky cases in tests at the very
least.
BurntSushi added a commit that referenced this pull request Sep 18, 2025
We don't attempt to fix these yet. I think there are bigger fish to fry.

I came up with these based on this discussion:
#20439 (comment)

Here's one example:

```
if ...:
    from foo import MAGIC
else:
    from bar import MAGIC

MAG<CURSOR>
```

Now in this example, completions will include `MAGIC` from the local
scope. That is, auto-import is involved with that completion. But at
present, auto-import will suggest importing `foo` and `bar` because we
haven't de-duplicated completions yet. Which is fine.

Here's another example:

```
if ...:
    import foo as fubar
else:
    import bar as fubar

MAG<CURSOR>
```

Now here, there is no `MAGIC` symbol in scope. So auto-import is in
play. Let's assume that the user selects `MAGIC` from `foo` in this
example. (`bar` also has `MAGIC`.)

Since we currently ignore the declaration site for symbols with
multiple possible bindings, the importer today doesn't know that
`fubar` _could_ contain `MAGIC`. But even if it did, what would we do
with that information? Should we do this?

```
if ...:
    import foo as fubar
    from foo import MAGIC
else:
    import bar as fubar

MAGIC
```

Or could we reason that `bar` also has `MAGIC`?

```
if ...:
    import foo as fubar
else:
    import bar as fubar

fubar.MAGIC
```

But if we did that, we're making an assumption of user intent, since
they *selected* `foo.MAGIC` but not `bar.MAGIC`.

Anyway, I don't think we need to settle on an answer today, but I
wanted to capture some of these tricky cases in tests at the very
least.
BurntSushi added a commit that referenced this pull request Sep 19, 2025
We don't attempt to fix these yet. I think there are bigger fish to fry.

I came up with these based on this discussion:
#20439 (comment)

Here's one example:

```
if ...:
    from foo import MAGIC
else:
    from bar import MAGIC

MAG<CURSOR>
```

Now in this example, completions will include `MAGIC` from the local
scope. That is, auto-import is involved with that completion. But at
present, auto-import will suggest importing `foo` and `bar` because we
haven't de-duplicated completions yet. Which is fine.

Here's another example:

```
if ...:
    import foo as fubar
else:
    import bar as fubar

MAG<CURSOR>
```

Now here, there is no `MAGIC` symbol in scope. So auto-import is in
play. Let's assume that the user selects `MAGIC` from `foo` in this
example. (`bar` also has `MAGIC`.)

Since we currently ignore the declaration site for symbols with
multiple possible bindings, the importer today doesn't know that
`fubar` _could_ contain `MAGIC`. But even if it did, what would we do
with that information? Should we do this?

```
if ...:
    import foo as fubar
    from foo import MAGIC
else:
    import bar as fubar

MAGIC
```

Or could we reason that `bar` also has `MAGIC`?

```
if ...:
    import foo as fubar
else:
    import bar as fubar

fubar.MAGIC
```

But if we did that, we're making an assumption of user intent, since
they *selected* `foo.MAGIC` but not `bar.MAGIC`.

Anyway, I don't think we need to settle on an answer today, but I
wanted to capture some of these tricky cases in tests at the very
least.
@rushter
Copy link

rushter commented Sep 19, 2025

Demo:

even-slicker-import-demo.mp4

What lsp feature displays the import path (green text)?

In helix, I just see suggested names without imports. They are from different modules and there is no way to tell which is correct.

image

@BurntSushi
Copy link
Member Author

We put it in CompletionItemLabelDetails.detail. It does require LSP 3.17 though.

@rushter
Copy link

rushter commented Sep 19, 2025

We put it in CompletionItemLabelDetails.detail. It does require LSP 3.17 though.

Weird, I think they do support it. I tested ty on 0.0.1-alpha.21, not from GitHub branch.

https://github.com/helix-editor/helix/blob/0ae37dc52ba715100893c327414bcb1a1924a4c3/helix-lsp-types/src/completion.rs#L559

I can see them in other LSPs.

image

@rushter
Copy link

rushter commented Sep 19, 2025

I'm getting the same behavior in Zed as well. Will test it more tomorrow.

image

@BurntSushi
Copy link
Member Author

Weird, I think they do support it. I tested ty on 0.0.1-alpha.21, not from GitHub branch.

Hmmm, this might be related and I don't think made it into 0.0.1-alpha.21? Although that shouldn't prevent it from showing up at all.

Will test it more tomorrow.

Thank you!!! <3

I can see them in other LSPs.

Hmmm that's not showing the import path though as far as I can see. It's just showing the kind. Although ty should be including the kind too, and that doesn't seem to be showing up for you either.

@rushter
Copy link

rushter commented Sep 19, 2025

Weird, I think they do support it. I tested ty on 0.0.1-alpha.21, not from GitHub branch.

Hmmm, this might be related and I don't think made it into 0.0.1-alpha.21? Although that shouldn't prevent it from showing up at all.

Will test it more tomorrow.

Thank you!!! <3

I can see them in other LSPs.

Hmmm that's not showing the import path though as far as I can see. It's just showing the kind. Although ty should be including the kind too, and that doesn't seem to be showing up for you either.

Installed from the main branch. Here is what I see in LSP logs of helix:

 {
      "additionalTextEdits": [
        {
          "newText": "from selectolax.parser import HTMLParser\n",
          "range": { "end": { "character": 0, "line": 7 }, "start": { "character": 0, "line": 7 } }
        }
      ],
      "insertText": "HTMLParser",
      "kind": 7,
      "label": "HTMLParser",
      "labelDetails": { "detail": " (import selectolax.parser)" },
      "sortText": " 56"
    },
    {
      "additionalTextEdits": [
        {
          "newText": "from html.parser import HTMLParser\n",
          "range": { "end": { "character": 0, "line": 7 }, "start": { "character": 0, "line": 7 } }
        }
      ],
      "insertText": "HTMLParser",
      "kind": 7,
      "label": "HTMLParser",
      "labelDetails": { "detail": " (import html.parser)" },
      "sortText": " 57"
    },

So I guess label details are not fully supported. Not sure why kind does not work though...

@rushter
Copy link

rushter commented Sep 20, 2025

Checked how it's done in other LSP.s
Rust analyzer just appends it to label:

        "label": "PatternMatchAs(use ruff_python_ast::PatternMatchAs)",

gopls puts it in the description (if it's a module import).:

"label": "unsafeheader",
"kind": 9,
 "detail": "\"internal/unsafeheader\"",

It looks like even Zed does not support labelDetails yet, so perhaps it's better to put it somewhere else for now.

@BurntSushi
Copy link
Member Author

Oh nice find! I missed that in r-a. I think you're right that stuffing it somewhere else if the LSP doesn't support labelDetails would be wise.

BurntSushi added a commit that referenced this pull request Oct 28, 2025
…supported

This fixes a bug where the `import module` part of a completion for
unimported candidates would be missing. This makes it especially
confusing because the user can't tell where the symbol is coming from,
and there is no hint that an `import` statement will be inserted.

Previously, we were using [`CompletionItemLabelDetails`] to render the
`import module` part of the suggestion. But this is only supported in
clients that support version 3.17 (or newer) of the LSP specification.
It turns out that this support isn't widespread yet. In particular,
Heliex doesn't seem to support "label details."

To fix this, we take a [cue from rust-analyzer][rust-analyzer-details].
We detect if the client supports "label details," and if so, use it.
Otherwise, we push the `import module` text into the completion label
itself.

Fixes #20439 (comment)

[`CompletionItemLabelDetails`]: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#completionItemLabelDetails
[rust-analyzer-details]: https://github.com/rust-lang/rust-analyzer/blob/5d905576d49233ed843bb40df4ed57e5534558f5/crates/rust-analyzer/src/lsp/to_proto.rs#L391-L404
@BurntSushi
Copy link
Member Author

@rushter I didn't forget about your bug! It should be fixed in #21109 :-)

@rushter
Copy link

rushter commented Oct 28, 2025

@rushter I didn't forget about your bug! It should be fixed in #21109 :-)

Thanks! I was checking the recent ty changes yesterday for autocomplete improvements :)

BurntSushi added a commit that referenced this pull request Oct 29, 2025
…supported

This fixes a bug where the `import module` part of a completion for
unimported candidates would be missing. This makes it especially
confusing because the user can't tell where the symbol is coming from,
and there is no hint that an `import` statement will be inserted.

Previously, we were using [`CompletionItemLabelDetails`] to render the
`import module` part of the suggestion. But this is only supported in
clients that support version 3.17 (or newer) of the LSP specification.
It turns out that this support isn't widespread yet. In particular,
Heliex doesn't seem to support "label details."

To fix this, we take a [cue from rust-analyzer][rust-analyzer-details].
We detect if the client supports "label details," and if so, use it.
Otherwise, we push the `import module` text into the completion label
itself.

Fixes #20439 (comment)

[`CompletionItemLabelDetails`]: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#completionItemLabelDetails
[rust-analyzer-details]: https://github.com/rust-lang/rust-analyzer/blob/5d905576d49233ed843bb40df4ed57e5534558f5/crates/rust-analyzer/src/lsp/to_proto.rs#L391-L404
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

server Related to the LSP server ty Multi-file analysis & type inference

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants