Skip to content

[ty] Support diagnostics in newly created files inside neovim#23095

Merged
BurntSushi merged 1 commit intomainfrom
ag/neovim-fubar-diagnostics
Feb 10, 2026
Merged

[ty] Support diagnostics in newly created files inside neovim#23095
BurntSushi merged 1 commit intomainfrom
ag/neovim-fubar-diagnostics

Conversation

@BurntSushi
Copy link
Member

This fixes an issue where one could open a new Python file in neovim,
save it, write some code but not get any diagnostics from ty.

There are two separate issues here.

One is that we currently use CheckMode::AllFiles by default and this
specifically ignores opened files that haven't been picked up as a
project file on disk yet. This PR does not address that issue. Notably,
we do have a CheckMode::OpenFiles, but I'm currently not clear on why
AllFiles explicitly ignores open files.

The second issue is that even after the file is saved on disk, our LSP
doesn't add it to its internal project state. Such that once the client
asks for diagnostics, we return nothing. This seems like a state
synchronization issue, because if you create a second new file, then
this will force directory re-scanning and cause the LSP to pick up the
first file created. That is, when a file is "opened," ty will do a
directory scan. But if the opened file doesn't actually exist on disk
yet, it won't see it.

The third issue is that workspace/didChangeWatchedFiles is turned off
by default in neovim's LSP client on Linux for performance reasons. It
can be turned back on with this config:

local capabilities = vim.lsp.protocol.make_client_capabilities()
capabilities.workspace.didChangeWatchedFiles.dynamicRegistration = true

vim.lsp.config('ty', {
  capabilities = capabilities,
})
vim.lsp.enable('ty')

Once enabled, everything almost works, except that neovim will
sometimes send a CHANGE event without a corresponding CREATED event.
This also messes up our state handling because a CHANGE event never
results in rescanning the directory to pick up new files.

This PR attempts to address this problem somewhat narrowly by adding
some logic that will force a directory rescan in response to a CHANGE
event. Specifically, when all of the following is true:

  • The CHANGE event refers to a file.
  • The file is considered to be included in the project.
  • It already has a File inside our salsa DB.
  • The project.files() doesn't contain it yet.

This, in combination with enabling workspace/didChangeWatchedFiles,
causes ty to pick up newly added and saved to disk files.

Fixes astral-sh/ty#2616

@BurntSushi
Copy link
Member Author

Demo:

2026-02-05T12.16.18-05.00.mp4

@astral-sh-bot
Copy link

astral-sh-bot bot commented Feb 5, 2026

Typing conformance results

No changes detected ✅

@astral-sh-bot
Copy link

astral-sh-bot bot commented Feb 5, 2026

mypy_primer results

Changes were detected when running on open source projects
porcupine (https://github.com/Akuli/porcupine)
- porcupine/pluginmanager.py:133:49: error[invalid-argument-type] Argument to function `__new__` is incorrect: Expected `Iterable[Never]`, found `Unknown | str`
- Found 25 diagnostics
+ Found 24 diagnostics

prefect (https://github.com/PrefectHQ/prefect)
+ src/integrations/prefect-docker/tests/test_containers.py:27:47: error[invalid-argument-type] Argument is incorrect: Expected `str`, found `str | bool | dict[Unknown | str, Unknown | int] | None`
+ src/integrations/prefect-docker/tests/test_containers.py:27:47: error[invalid-argument-type] Argument is incorrect: Expected `str | list[str] | None`, found `str | bool | dict[Unknown | str, Unknown | int] | None`
+ src/integrations/prefect-docker/tests/test_containers.py:27:47: error[invalid-argument-type] Argument is incorrect: Expected `str | None`, found `str | bool | dict[Unknown | str, Unknown | int] | None`
+ src/integrations/prefect-docker/tests/test_containers.py:27:47: error[invalid-argument-type] Argument is incorrect: Expected `bool | None`, found `str | bool | dict[Unknown | str, Unknown | int] | None`
+ src/integrations/prefect-docker/tests/test_containers.py:27:47: error[invalid-argument-type] Argument is incorrect: Expected `str | list[str] | None`, found `str | bool | dict[Unknown | str, Unknown | int] | None`
+ src/integrations/prefect-docker/tests/test_containers.py:27:47: error[invalid-argument-type] Argument is incorrect: Expected `dict[str, str] | list[str] | None`, found `str | bool | dict[Unknown | str, Unknown | int] | None`
+ src/integrations/prefect-docker/tests/test_containers.py:27:47: error[invalid-argument-type] Argument is incorrect: Expected `dict[str, Any]`, found `str | bool | dict[Unknown | str, Unknown | int] | None`
+ src/integrations/prefect-docker/tests/test_containers.py:42:47: error[invalid-argument-type] Argument is incorrect: Expected `dict[str, Any]`, found `str`
+ src/integrations/prefect-docker/tests/test_containers.py:55:47: error[invalid-argument-type] Argument is incorrect: Expected `dict[str, Any]`, found `str`
+ src/integrations/prefect-docker/tests/test_containers.py:68:47: error[invalid-argument-type] Argument is incorrect: Expected `dict[str, Any]`, found `str`
+ src/integrations/prefect-docker/tests/test_containers.py:81:47: error[invalid-argument-type] Argument is incorrect: Expected `dict[str, Any]`, found `str`
+ src/integrations/prefect-docker/tests/test_images.py:16:44: error[invalid-argument-type] Argument is incorrect: Expected `str`, found `str | bool`
+ src/integrations/prefect-docker/tests/test_images.py:16:44: error[invalid-argument-type] Argument is incorrect: Expected `str | None`, found `str | bool`
+ src/integrations/prefect-docker/tests/test_images.py:16:44: error[invalid-argument-type] Argument is incorrect: Expected `str | None`, found `str | bool`
+ src/integrations/prefect-docker/tests/test_images.py:16:44: error[invalid-argument-type] Argument is incorrect: Expected `bool`, found `str | bool`
+ src/integrations/prefect-docker/tests/test_images.py:16:44: error[invalid-argument-type] Argument is incorrect: Expected `DockerHost | None`, found `str | bool`
+ src/integrations/prefect-docker/tests/test_images.py:16:44: error[invalid-argument-type] Argument is incorrect: Expected `DockerRegistryCredentials | None`, found `str | bool`
+ src/integrations/prefect-docker/tests/test_images.py:16:44: error[invalid-argument-type] Argument is incorrect: Expected `dict[str, Any]`, found `str | bool`
+ src/integrations/prefect-docker/tests/test_images.py:21:16: warning[possibly-missing-attribute] Attribute `id` may be missing on object of type `Unknown | list[Unknown]`
+ src/integrations/prefect-docker/tests/test_images.py:29:47: error[invalid-argument-type] Argument is incorrect: Expected `bool`, found `str`
+ src/integrations/prefect-docker/tests/test_images.py:29:47: error[invalid-argument-type] Argument is incorrect: Expected `DockerRegistryCredentials | None`, found `str`
+ src/integrations/prefect-docker/tests/test_images.py:29:47: error[invalid-argument-type] Argument is incorrect: Expected `dict[str, Any]`, found `str`
+ src/integrations/prefect-docker/tests/test_images.py:31:16: warning[possibly-missing-attribute] Attribute `id` may be missing on object of type `Unknown | list[Unknown]`
+ src/integrations/prefect-docker/tests/test_images.py:51:17: error[invalid-argument-type] Argument is incorrect: Expected `bool`, found `str`
+ src/integrations/prefect-docker/tests/test_images.py:51:17: error[invalid-argument-type] Argument is incorrect: Expected `dict[str, Any]`, found `str`
+ src/integrations/prefect-docker/tests/test_images.py:53:16: warning[possibly-missing-attribute] Attribute `id` may be missing on object of type `Unknown | list[Unknown]`
+ src/integrations/prefect-docker/tests/test_images.py:64:47: error[invalid-argument-type] Argument is incorrect: Expected `str`, found `str | bool`
+ src/integrations/prefect-docker/tests/test_images.py:64:47: error[invalid-argument-type] Argument is incorrect: Expected `str | None`, found `str | bool`
+ src/integrations/prefect-docker/tests/test_images.py:64:47: error[invalid-argument-type] Argument is incorrect: Expected `str | None`, found `str | bool`
+ src/integrations/prefect-docker/tests/test_images.py:64:47: error[invalid-argument-type] Argument is incorrect: Expected `bool`, found `str | bool`
+ src/integrations/prefect-docker/tests/test_images.py:64:47: error[invalid-argument-type] Argument is incorrect: Expected `DockerRegistryCredentials | None`, found `str | bool`
+ src/integrations/prefect-docker/tests/test_images.py:64:47: error[invalid-argument-type] Argument is incorrect: Expected `dict[str, Any]`, found `str | bool`
+ src/integrations/prefect-kubernetes/prefect_kubernetes/jobs.py:428:17: error[invalid-argument-type] Argument is incorrect: Expected `dict[str, Any]`, found `str`
+ src/integrations/prefect-kubernetes/tests/test_custom_objects.py:20:13: error[invalid-argument-type] Argument is incorrect: Expected `dict[str, Any]`, found `None`
+ src/integrations/prefect-kubernetes/tests/test_custom_objects.py:29:13: error[invalid-argument-type] Argument is incorrect: Expected `dict[str, Any]`, found `None`
+ src/integrations/prefect-kubernetes/tests/test_custom_objects.py:38:13: error[invalid-argument-type] Argument is incorrect: Expected `dict[str, Any]`, found `None`
+ src/integrations/prefect-kubernetes/tests/test_custom_objects.py:57:9: error[invalid-argument-type] Argument is incorrect: Expected `dict[str, Any]`, found `Literal["test"]`
+ src/integrations/prefect-kubernetes/tests/test_custom_objects.py:103:9: error[invalid-argument-type] Argument is incorrect: Expected `dict[str, Any]`, found `Literal["test"]`
+ src/integrations/prefect-kubernetes/tests/test_custom_objects.py:149:9: error[invalid-argument-type] Argument is incorrect: Expected `dict[str, Any]`, found `Literal["test"]`
+ src/integrations/prefect-kubernetes/tests/test_custom_objects.py:195:9: error[invalid-argument-type] Argument is incorrect: Expected `dict[str, Any]`, found `Literal["test"]`
+ src/integrations/prefect-kubernetes/tests/test_custom_objects.py:240:9: error[invalid-argument-type] Argument is incorrect: Expected `dict[str, Any]`, found `Literal["test"]`
+ src/integrations/prefect-kubernetes/tests/test_custom_objects.py:286:9: error[invalid-argument-type] Argument is incorrect: Expected `dict[str, Any]`, found `Literal["test"]`
+ src/integrations/prefect-kubernetes/tests/test_custom_objects.py:344:9: error[invalid-argument-type] Argument is incorrect: Expected `dict[str, Any]`, found `Literal["test"]`
+ src/integrations/prefect-kubernetes/tests/test_deployments.py:18:9: error[invalid-argument-type] Argument is incorrect: Expected `dict[str, Any]`, found `Literal["test"]`
+ src/integrations/prefect-kubernetes/tests/test_deployments.py:38:9: error[invalid-argument-type] Argument is incorrect: Expected `dict[str, Any]`, found `Literal["test"]`
+ src/integrations/prefect-kubernetes/tests/test_deployments.py:70:9: error[invalid-argument-type] Argument is incorrect: Expected `dict[str, Any]`, found `Literal["test"]`
+ src/integrations/prefect-kubernetes/tests/test_deployments.py:92:9: error[invalid-argument-type] Argument is incorrect: Expected `dict[str, Any]`, found `Literal["test"]`
+ src/integrations/prefect-kubernetes/tests/test_deployments.py:113:9: error[invalid-argument-type] Argument is incorrect: Expected `dict[str, Any]`, found `Literal["test"]`
+ src/integrations/prefect-kubernetes/tests/test_deployments.py:141:9: error[invalid-argument-type] Argument is incorrect: Expected `dict[str, Any]`, found `Literal["test"]`
+ src/integrations/prefect-kubernetes/tests/test_jobs.py:36:9: error[invalid-argument-type] Argument is incorrect: Expected `dict[str, Any]`, found `Literal["test"]`
+ src/integrations/prefect-kubernetes/tests/test_jobs.py:52:9: error[invalid-argument-type] Argument is incorrect: Expected `dict[str, Any]`, found `Literal["test"]`
+ src/integrations/prefect-kubernetes/tests/test_jobs.py:68:9: error[invalid-argument-type] Argument is incorrect: Expected `dict[str, Any]`, found `Literal["test"]`
+ src/integrations/prefect-kubernetes/tests/test_jobs.py:87:9: error[invalid-argument-type] Argument is incorrect: Expected `dict[str, Any]`, found `Literal["test"]`
+ src/integrations/prefect-kubernetes/tests/test_jobs.py:107:9: error[invalid-argument-type] Argument is incorrect: Expected `dict[str, Any]`, found `Literal["test"]`
+ src/integrations/prefect-kubernetes/tests/test_jobs.py:131:9: error[invalid-argument-type] Argument is incorrect: Expected `dict[str, Any]`, found `Literal["test"]`
+ src/integrations/prefect-kubernetes/tests/test_jobs.py:159:9: error[invalid-argument-type] Argument is incorrect: Expected `dict[str, Any]`, found `Literal["test"]`
+ src/integrations/prefect-kubernetes/tests/test_pods.py:29:9: error[invalid-argument-type] Argument is incorrect: Expected `dict[str, Any]`, found `Literal["test"]`
+ src/integrations/prefect-kubernetes/tests/test_pods.py:46:9: error[invalid-argument-type] Argument is incorrect: Expected `dict[str, Any]`, found `Literal["test"]`
+ src/integrations/prefect-kubernetes/tests/test_pods.py:78:9: error[invalid-argument-type] Argument is incorrect: Expected `dict[str, Any]`, found `Literal["test"]`
+ src/integrations/prefect-kubernetes/tests/test_pods.py:96:9: error[invalid-argument-type] Argument is incorrect: Expected `dict[str, Any]`, found `Literal["test"]`
+ src/integrations/prefect-kubernetes/tests/test_pods.py:115:9: error[invalid-argument-type] Argument is incorrect: Expected `dict[str, Any]`, found `Literal["test"]`
+ src/integrations/prefect-kubernetes/tests/test_pods.py:137:9: error[invalid-argument-type] Argument is incorrect: Expected `dict[str, Any]`, found `Literal["test"]`
+ src/integrations/prefect-kubernetes/tests/test_pods.py:167:9: error[invalid-argument-type] Argument is incorrect: Expected `dict[str, Any]`, found `Literal["test"]`
- src/prefect/cache_policies.py:311:25: warning[possibly-missing-attribute] Attribute `__code__` may be missing on object of type `Unknown | (((...) -> Any) & ((*args: object, **kwargs: object) -> object))`
+ src/prefect/cache_policies.py:311:25: warning[possibly-missing-attribute] Attribute `__code__` may be missing on object of type `Unknown | ((...) -> Any)`
- src/prefect/input/run_input.py:672:20: error[invalid-return-type] Return type does not match returned value: expected `T@GetAutomaticInputHandler | AutomaticRunInput[T@GetAutomaticInputHandler]`, found `T@GetAutomaticInputHandler | AutomaticRunInput[T@GetAutomaticInputHandler] | Coroutine[Any, Any, T@GetAutomaticInputHandler | AutomaticRunInput[T@GetAutomaticInputHandler]]`
+ src/prefect/input/run_input.py:672:20: error[invalid-return-type] Return type does not match returned value: expected `T@GetAutomaticInputHandler | AutomaticRunInput[T@GetAutomaticInputHandler]`, found `Unknown | Coroutine[Any, Any, Unknown]`
+ src/prefect/task_engine.py:1641:28: error[invalid-await] `Unknown | R@AsyncTaskRunEngine | Coroutine[Any, Any, R@AsyncTaskRunEngine]` is not awaitable
+ src/prefect/task_engine.py:1749:47: error[invalid-argument-type] Argument to function `next` is incorrect: Expected `SupportsNext[Unknown]`, found `Unknown | R@run_generator_task_sync`
+ src/prefect/task_engine.py:1762:25: warning[possibly-missing-attribute] Attribute `throw` may be missing on object of type `Unknown | R@run_generator_task_sync`
+ src/prefect/task_engine.py:1808:48: warning[possibly-missing-attribute] Attribute `__anext__` may be missing on object of type `Unknown | R@run_generator_task_async`
+ src/prefect/task_engine.py:1820:29: warning[possibly-missing-attribute] Attribute `throw` may be missing on object of type `Unknown | R@run_generator_task_async`
- src/prefect/tasks.py:185:9: warning[possibly-missing-attribute] Attribute `__code__` may be missing on object of type `Unknown | (((...) -> Any) & ((*args: object, **kwargs: object) -> object))`
+ src/prefect/tasks.py:185:9: warning[possibly-missing-attribute] Attribute `__code__` may be missing on object of type `Unknown | ((...) -> Any)`
- Found 5437 diagnostics
+ Found 5505 diagnostics

materialize (https://github.com/MaterializeInc/materialize)
- misc/python/materialize/cli/mz_workload_anonymize.py:251:13: error[no-matching-overload] No overload of bound method `join` matches arguments
- Found 535 diagnostics
+ Found 534 diagnostics

@ntBre ntBre added the ty Multi-file analysis & type inference label Feb 5, 2026
@dhruvmanila
Copy link
Member

One is that we currently use CheckMode::AllFiles by default

I think this is only true when invoked from the command-line because the language server uses OpenFilesOnly as the default value.

but I'm currently not clear on why
AllFiles explicitly ignores open files.

This is because open files which doesn't exists on disk is a virtual file but it seems that Neovim seems to be using a regular file:// URI when such files are opened in the editor:

2026-02-06 17:09:22 [DEBUG] rpc.send {
  jsonrpc = "2.0",
  method = "textDocument/didOpen",
  params = {
    textDocument = {
      languageId = "python",
      text = "\n",
      uri = "file:///Users/dhruv/playground/ty/bug-report-1/other.py",
      version = 0
    }
  }
}

Virtual files are indicated with untitled:// URI and we use that information to categorize them internally as well. Related issue for the Ruff server: #15392

@BurntSushi
Copy link
Member Author

I think this is only true when invoked from the command-line because the language server uses OpenFilesOnly as the default value.

I sent you some messages, but I don't think this is true. From neovim at least, I actually can't make the server operate in OpenFilesOnly mode even when I explicitly configure it as such.

Still though, even if diagnosticMode is set to workspace, I'd still wonder about why it doesn't check open files. It seems to explicitly exclude them.

@BurntSushi BurntSushi force-pushed the ag/neovim-fubar-diagnostics branch from b7daafd to d2e4e63 Compare February 6, 2026 13:24
@BurntSushi BurntSushi added the server Related to the LSP server label Feb 6, 2026
Comment on lines 127 to 130
if self.system().is_file(&path)
&& project.is_file_included(self, &path)
&& let Some(file) = self.files().try_system(self, &path)
&& !project.files(self).contains(&file)
Copy link
Member

Choose a reason for hiding this comment

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

Can we add a test case for this scenario (e2e preferably)?

Copy link
Member Author

Choose a reason for hiding this comment

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

Yes! I added three. :-)

I think this might also fix #15392?

Copy link
Member

Choose a reason for hiding this comment

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

That issue is specific to the Ruff server so it might require a different solution as the architecture differs.

@BurntSushi BurntSushi force-pushed the ag/neovim-fubar-diagnostics branch from d2e4e63 to 3b9de75 Compare February 9, 2026 19:18
@astral-sh-bot
Copy link

astral-sh-bot bot commented Feb 9, 2026

ruff-ecosystem results

Linter (stable)

✅ ecosystem check detected no linter changes.

Linter (preview)

✅ ecosystem check detected no linter changes.

This fixes an issue where one could open a new Python file in neovim,
save it, write some code but not get any diagnostics from ty.

There are two separate issues here.

One is that we currently use `CheckMode::AllFiles` by default and this
_specifically_ ignores opened files that haven't been picked up as a
project file on disk yet. This PR does not address that issue. Notably,
we do have a `CheckMode::OpenFiles`, but I'm currently not clear on why
`AllFiles` explicitly ignores open files.

The second issue is that even after the file is saved on disk, our LSP
doesn't add it to its internal project state. Such that once the client
asks for diagnostics, we return nothing. This seems like a state
synchronization issue, because if you create a second new file, then
this will force directory re-scanning and cause the LSP to pick up the
first file created. That is, when a file is "opened," ty will do a
directory scan. But if the opened file doesn't actually exist on disk
yet, it won't see it.

The third issue is that `workspace/didChangeWatchedFiles` is turned off
by default in neovim's LSP client on Linux for performance reasons. It
can be turned back on with this config:

```lua
local capabilities = vim.lsp.protocol.make_client_capabilities()
capabilities.workspace.didChangeWatchedFiles.dynamicRegistration = true

vim.lsp.config('ty', {
  capabilities = capabilities,
})
vim.lsp.enable('ty')
```

Once enabled, everything _almost_ works, except that neovim will
sometimes send a CHANGE event without a corresponding CREATED event.
This also messes up our state handling because a CHANGE event never
results in rescanning the directory to pick up new files.

This PR addresses this problem by always checking the open file
set instead of relying on our virtual path detection to always be
correct. Notably, neovim does not follow the LSP convention of using an
`untitled://...` scheme for documents that do not yet exist on disk,
but we rely on that convention to determine whether a file is virtual
or not.

Fixes astral-sh/ty#2616, Ref #15392
@BurntSushi BurntSushi force-pushed the ag/neovim-fubar-diagnostics branch from ea48e1c to a7198dd Compare February 10, 2026 11:54
@astral-sh-bot
Copy link

astral-sh-bot bot commented Feb 10, 2026

Memory usage report

Memory usage unchanged ✅

@BurntSushi
Copy link
Member Author

I'm going to bring this in. We can revisit the wisdom of always checking the open file set if issues come up. I think it's probably a better change than what I had before, which was trying to interpret CHANGE events as CREATION events in some circumstances.

@BurntSushi BurntSushi merged commit 0613661 into main Feb 10, 2026
50 checks passed
@BurntSushi BurntSushi deleted the ag/neovim-fubar-diagnostics branch February 10, 2026 12:09
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.

No diagnostics in neovim 0.11 with native lsp

3 participants