Repository navigation
LSP load issue #161
Description
Bug Report: New .py file opened via :e in a running nvim never gets its filetype set
1. Environment
- nvim: v0.12.4 (
/usr/local/share/nvim) - Distro: LazyVim (
~/.config/lazyvim/) - lazy.nvim: 11.17.5
- Plugins dir:
~/.local/share/lazyvim/lazy/ - Extras enabled:
lang.python,lang.toml - User config diff:
lua/plugins/treesitter.lua(disables treesitter indent for python) - nvim source inspected:
/tmp/nvim-src/src/nvim/(from the same version)
2. Symptom
nvim new.pyfrom the CLI: works (filetype =python, LSP attaches).:e new.pyfrom inside a running nvim (deferred, i.e. normal interactive use): broken —vim.bo.ftstays"", noFileTypeevent fires, so treesitter/LSP etc. never load.:eissued during startup works;:eissued after startup is broken → timing dependent.
3. Reproduction setup
All tests run headless with the real user config:
env NVIM_APPNAME=lazyvim nvim --headless -u ~/.config/lazyvim/init.lua \
"+lua dofile('/tmp/lazyvim_repro/traceNN.lua')"
The trace script schedules a vim.defer_fn that runs :e /tmp/.../traceNN.py ~2.5s after startup (i.e. in a running session, vim.v.vim_did_enter == 1), then reports vim.bo.ft and vim.fn.did_filetype().
4. Tests run
4.1. Reproduce & confirm baseline behavior
| Test | Scenario | Result |
|---|---|---|
trace25 |
deferred :e new.py (no instrumentation of LSP) |
ft= did=0 → broken |
trace33_nofix |
deferred :e new.py |
ft= did=0 → broken |
trace33 + lazy=false override |
deferred :e new.py |
ft=python did=0 → fixed |
CLI argv (trace45, no fix) |
nvim new.py at startup |
ft=python, LSP: pyright + ruff → works |
4.2. Show the ordering of events in the broken case
trace28 — vim.cmd.doautoall is called during the BufNewFile cascade, before filetype detection:
[GO] e new
[DOAU] doautoall { "nvim.lsp.enable FileType" } did=0
[DOAU] doautoall { "nvim.lsp.enable FileType" } did=1
[DOAU] doautoall { "nvim.lsp.enable FileType" } did=1
[DOAU] doautoall { "nvim.lsp.enable FileType" } did=1
[AFTER] ft= did=0
did_filetype() flips 0 → 1 on the very first doautoall, while ft is still "".
trace29 — detection callback runs after the flips, but its setf is a no-op:
[GO] e new
[DOAU] { "nvim.lsp.enable FileType" } ft-before= did-before=0
[DOAU] { "nvim.lsp.enable FileType" } ft-before= did-before=1
[DOAU] { "nvim.lsp.enable FileType" } ft-before= did-before=1
[DOAU] { "nvim.lsp.enable FileType" } ft-before= did-before=1
[FTDETECT-LATE] ft= did=1
[AFTER] ft= did=0
4.3. Identify what triggers the doautoall
trace31 — vim.lsp.enable() is invoked during the lazy-load, first call happens while did=0:
[GO] e new
[LSP.ENABLE] lua_ls did=0
[LSP.ENABLE] pyright did=1
[LSP.ENABLE] ruff did=1
[LSP.ENABLE] taplo did=1
[AFTER] ft= did=0
trace38 — backtrace of vim.lsp.enable in the broken case (abridged):
mason-lspconfig/features/automatic_enable.lua:47 vim.lsp.enable(server)
mason-lspconfig/init.lua:43 setup()
lazyvim/plugins/lsp/init.lua:272 config() <- nvim-lspconfig CONFIG
lazy/core/loader.lua:395 loader.config
lazy/core/handler/event.lua:85 lazy_handler_event (BufNewFile)
4.4. Isolate the exact cause (causal proof)
trace32 — monkey-patching vim.cmd.doautoall to skip nvim.lsp.enable FileType:
[GO] e new
[SKIP-DOAU] nvim.lsp.enable FileType (x4)
[FT-EVT] ft=python
[FT-EVT] ft=python
[AFTER] ft=python did=0
→ With the single doautoall suppressed, detection works. This is direct causal proof.
4.5. Candidate fixes
| Test | Override | Result |
|---|---|---|
trace33 / trace44 / trace53 / t88_fixA |
nvim-lspconfig lazy = false (load at startup) |
ft=python, did=0 → fixed |
trace33_vl / trace35_vl / trace36_vl |
nvim-lspconfig event = "VeryLazy" |
ft= did=0 → NOT fixed |
t88_fixB |
event = function() return { "BufReadPre" } end |
ft=python but plugin never loads → no LSP → rejected |
t88_fixC |
event = function() return { "BufReadPre", "BufNewFile" } end |
reproduces bug (ft= empty) → confirms BufNewFile is the trigger |
t88_fixD / t88_fixE |
defer vim.lsp.enable via vim.schedule (proper fix) |
ft=python + pyright/ruff attach → fixed, LSP preserved |
The VeryLazy variant does not help: lazy.nvim still lazy-loads nvim-lspconfig (and its deps mason.nvim/mason-lspconfig.nvim) during the BufNewFile cascade — at that point vim.v.vim_did_enter == 1, so vim.lsp.enable still runs its doautoall.
Why every plain event = "..." override failed: lazy.nvim merges list-valued spec props (event, cmd, ft, keys) with Util.extend (lazy/core/plugin.lua:464-465), which concatenates the base LazyVim spec's event with the override — you can only add triggers, never remove them. So event = "BufReadPost" merged to { "BufReadPre", "BufNewFile", "BufReadPost" } and BufNewFile still fired (verified: Handler.handlers.event.active["BufNewFile"] still contained nvim-lspconfig).
The one override form that replaces: a function-valued event (M._values at plugin.lua:458-461 treats functions as a full replacement). But dropping BufNewFile (fix B) fixed the filetype while breaking LSP entirely for new files (BufReadPre never fires for :e newfile.py), and keeping both (fix C) reproduced the bug. So the event cannot be the fix surface.
4.6. The proper fix (verified): defer vim.lsp.enable
Root cause is the synchronous vim.lsp.enable running inside the BufNewFile load cascade. Deferring it past the cascade preserves lazy-loading on BufNewFile and fixes the filetype:
| Test | Scenario | Result |
|---|---|---|
t88_fixE CLI |
nvim newfile.py |
ft=python, clients pyright,ruff, enabled pyright,ruff,lua_ls |
t88_fixE deferred |
:e newfile.py in running session |
ft=python, clients pyright,ruff, enabled pyright,ruff,lua_ls |
t88_fixA (lazy=false) control |
same two scenarios | identical results |
Both fixes now produce identical, correct output. The earlier "CLIENTS 0" headless readings in §7 were an artifact of vim.loop.sleep in the trace harness blocking the event loop / LSP client attach — with a vim.defer_fn-chained trace, clients attach in the deferred case too.
5. Root cause (fully confirmed)
:e new.pyfiresBufNewFile.- lazy.nvim's
lazy_handler_eventautocmds (registered before thefiletypedetectgroup, so they run first) lazy-loadnvim-lspconfig— LazyVim defines it withevent = { "BufReadPre", "BufNewFile" }inlua/lazyvim/plugins/lsp/init.lua:5. - During the load, LazyVim's config runs
mason-lspconfig.setup()→vim.lsp.enable("lua_ls")etc. (automatic_enable.lua:47). - nvim runtime
vim.lsp.enable(/usr/local/share/nvim/runtime/lua/vim/lsp.lua:656-658) seesvim.v.vim_did_enter == 1(running session) and executes:doautoall nvim.lsp.enable FileType. - That fires the
FileTypeevent on the current buffer →apply_autocmds_groupsetscurbuf->b_did_filetype = trueat the start of the batch (src/nvim/autocmd.c:1879). - When the built-in
filetypedetectcallback (/usr/local/share/nvim/runtime/filetype.lua:8-35) finally runs,vim.filetype.match()returns"python"and it calls:setf python— butex_setfiletypeearly-returns becauseb_did_filetypeis already true (src/nvim/ex_docmd.c:8162). - Net result:
vim.bo.ftstays"", noFileTypeevent withpythonever fires → treesitter/LSP/etc. never attach.
Why CLI/startup works
At startup vim.v.vim_did_enter == 0, so the doautoall branch in vim.lsp.enable (lsp.lua:656, condition vim.v.vim_did_enter == 1 or vim.fn.did_filetype() == 1) is skipped. That is the entire timing dependency.
Why VeryLazy override fails
It changes when lazy.nvim would load the plugin, but the trigger that actually pulls nvim-lspconfig in (and its deps mason.nvim + mason-lspconfig.nvim) is still the BufNewFile cascade, so vim.lsp.enable still runs with vim_did_enter == 1 → same doautoall → same breakage.
6. Possible fixes
Primary (verified, simplest): lazy = false
Load nvim-lspconfig at startup in ~/.config/lazyvim/lua/plugins/:
return {
{ "neovim/nvim-lspconfig", lazy = false },
}Verified: deferred :e new.py → ft=python, did_filetype()=0; CLI .py file still works. Works because vim.lsp.enable then runs at startup (vim_did_enter == 0) where the doautoall branch is skipped.
Proper fix (verified): defer vim.lsp.enable with vim.schedule
Keeps LSP lazy-loaded on BufNewFile (so LSP still starts for brand-new files) while letting filetype detection finish first. Add a tiny plugin that wraps vim.lsp.enable so the doautoall runs after the current autocmd batch completes:
return {
{
"nvim-lspconfig-enable-defer",
lazy = false,
init = function()
local orig_enable = vim.lsp.enable
vim.lsp.enable = function(server)
vim.schedule(function()
orig_enable(server)
end)
end
end,
},
}Verified (t88_fixD/fixE): deferred :e new.py → ft=python; CLI → ft=python + pyright,ruff clients. Both behave identically to the lazy = false control. This is the cleanest candidate to upstream as a LazyVim patch (lua/lazyvim/plugins/lsp/init.lua:264 and :272, or automatic_enable.lua:47).
Rejected overrides
event = "VeryLazy"— does not fix (still loaded onBufNewFile).- Any plain
event = "..."string/table override — lazy.nvim concatenates the base event, soBufNewFilecannot be removed (plugin.lua:464). event = function() ... endreturning only non-BufNewFiletriggers — replaces the event but breaks LSP for new files (BufReadPredoesn't fire for:e new.py).
Upstream neovim note: vim.lsp.enable's doautoall arguably should not mark b_did_filetype on the current buffer when ft is still empty. Related upstream issues: neovim/neovim#31641, neovim/neovim#33116.
7. Caveats / open items
- LSP attach in the deferred
:ecase was initially reported as intermittent (n=0in some headless runs). This was traced to the test harness usingvim.loop.sleep, which blocks the event loop and prevents scheduled LSP client attach. With avim.defer_fn-chained trace, clients attach reliably (pyright,ruff) under both fixes. An interactive confirmation remains a nice-to-have, not a blocker. - The
lazy = falsefix and thevim.scheduledefer fix both preserve correct CLI-argv behavior.
8. Conclusion
The bug is an autocmd-ordering race, not a python-specific config issue:
- lazy.nvim lazy-loads nvim-lspconfig on
BufNewFile, beforefiletypedetectruns. - During that load, LazyVim calls
vim.lsp.enable(), which (in a running session,vim_did_enter == 1) runs:doautoall nvim.lsp.enable FileType. - That
FileTypebatch setsb_did_filetype = trueon the current buffer ahead of detection, so the latersetf pythoninfiletypedetectbecomes a no-op (ex_setfiletypeearly return). - Fix options: (a) load nvim-lspconfig at startup (
lazy = false) sovim.lsp.enableruns whilevim_did_enter == 0and thedoautoallis skipped; or (b) defervim.lsp.enableviavim.scheduleso thedoautoallruns after detection — both verified, identical behavior. - Event-only overrides cannot fix it: lazy.nvim concatenates the base event (plugin.lua:464), so
BufNewFilecan't be removed; function-valued events replace but break LSP for new files.
Activity