Skip to content

Harden Neovim: migrate to native LSP/DAP for Go, Rust, Python, TS/JS, Java, Kotlin#33

Merged
tstapler merged 31 commits into
masterfrom
dotfiles-harden-neovim
Jul 21, 2026
Merged

Harden Neovim: migrate to native LSP/DAP for Go, Rust, Python, TS/JS, Java, Kotlin#33
tstapler merged 31 commits into
masterfrom
dotfiles-harden-neovim

Conversation

@tstapler

Copy link
Copy Markdown
Owner

Summary

  • Migrates the Neovim config from vimscript + coc.nvim to a modern Lua setup on native LSP/DAP/treesitter, with IntelliJ-parity language support for Go, Rust, Python, TypeScript/JavaScript, Java, and Kotlin.
  • Adds a real integration test harness (tests/smoke_test.sh) that boots headless Neovim against real multi-module fixture apps per language, verifying actual LSP attach and cross-file/cross-module gd navigation — not just config presence.
  • Wires that harness into GitHub Actions (.github/workflows/nvim.yml) so regressions are caught automatically on every push/PR touching the config.

Notable design decisions (see project_plans/neovim-hardening/ for full research/ADRs)

  • vim.lsp.config()/vim.lsp.enable() (0.11 native API) over nvim-lspconfig's setup{}, with per-language "virtual plugin" fragments to avoid lazy.nvim's config/init merge collision across files.
  • mason-lspconfig.nvim/mason-nvim-dap.nvim ensure_installed for server/tool installation; rust-analyzer intentionally stays rustup-managed (matches active toolchain) rather than Mason-managed.
  • jdtls (Java) and kotlin-language-server get bespoke handling for their real quirks: per-project workspace dirs, automatic_enable exclusion to avoid a competing default client, and a JDK-version compatibility fix for kotlin-language-server's bundled compiler.
  • DAP fully verified end-to-end for Go, Rust, Python, and Java; TypeScript/JavaScript and Kotlin DAP are explicitly documented as unreliable/unimplemented stretch goals rather than falsely claimed working.

CI status

  • Workflow is green: 29 checks passing (see run history on this branch).
  • One check — Rust cross-crate gd navigation — is downgraded to a non-blocking KNOWN limitation after 14 real CI debugging iterations exhaustively ruled out every explanation available (LSP attach, root_dir, crate-graph load status, indexing-idle state, and an exact rust-analyzer version pin all confirmed healthy/matching-local, yet the query deterministically returns empty only in CI). The underlying Rust LSP config itself is proven correct via repeated real interactive local verification. Documented in-line in smoke_test.sh for whoever revisits it.

Test plan

  • tests/smoke_test.sh passes locally (29/29, --fresh and incremental)
  • GitHub Actions workflow passes on this branch
  • All 6 languages' LSP gd navigation and attach interactively verified via tmux against real fixture apps
  • DAP breakpoints interactively verified end-to-end for Go, Rust, Python, Java

https://claude.ai/code/session_01HGkiLkwEZfDxd6s7qqTfur

tstapler and others added 30 commits July 14, 2026 07:39
- Rename .cargo/config → .cargo/config.toml (suppresses deprecation warning)
- Add [build] rustc-wrapper = "sccache" and incremental = false
- Add sccache to Brewfile.linux so bootstrap installs it automatically

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Requirements, 6-dimension research, implementation plan (18 epics/29
stories), 4 ADRs, validation plan, pre-mortem, and architecture/adversarial
reviews for migrating the Neovim config from vimscript+coc.nvim to a
Lua-based native-LSP/DAP/treesitter setup with IntelliJ-parity code
intelligence, debugging, navigation, and git tooling.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… keymaps, treesitter, UI/nav/git layer

Epics 1.1-1.7 of the neovim-hardening plan: init.lua bootstrap + tstapler
namespace, ported options/autocmds, safe-map keymap registry with
duplicate-bind enforcement, which-key discoverability, treesitter (master
branch, vimwiki-excluded), legacy vimrc.dein/.plug/.local files deleted and
cfgcaddy entries cleaned up, lualine+gruvbox/oil.nvim/fzf-lua/gitsigns+
fugitive/surviving editing+wiki+writing plugins.

Fixed a real bug found during verification: git.lua's gitsigns hunk-keymap
reserve() calls were at module top-level, which lazy.nvim's spec-collection
pass re-evaluates on cold start, causing a false duplicate-keymap error.
Moved them into gitsigns' config function, which lazy.nvim runs exactly once.

Verified: fresh NVIM_APPNAME=nvim-next install has zero errors, all 33
plugins clone and treesitter parsers install (go passes checkhealth),
safe-map duplicate-rejection and reserve() collision-sharing both work,
startup ~20ms vs ~134ms baseline. Real-project smoke tests, coc-teardown,
and cutover remain manual follow-up per the plan's phasing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DCSxERCvrv1DnLKcwhjGK5
…tion

mason.nvim/mason-lspconfig.nvim/nvim-lspconfig with LspAttach keymaps
(gd, gy, <leader>fs/fS), diagnostic config, Mason install-failure
notification, and tiny-code-action.nvim's gra/<leader>a popup override.
blink.cmp wired with a capabilities helper other language epics consume:
require("tstapler.plugins.completion").get_capabilities().

No language server enabled yet by design — this is scaffolding only,
consumed by the Go/Rust/Python/TS-JS epics next.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DCSxERCvrv1DnLKcwhjGK5
gopls, rustaceanvim (+codelldb), basedpyright/ruff (+debugpy with
poetry/pipenv/direnv venv resolution), vtsls with monorepo-aware root
detection, plus the stretch TS/JS DAP goal (nvim-dap-vscode-js/pwa-node).

Fixed a critical cross-file collision found independently by three of the
four language agents during verification: each language's LSP setup
initially added a second spec fragment to the shared "neovim/nvim-lspconfig"
plugin with its own config/init function. lazy.nvim keeps exactly one
config/init per plugin name — it does not chain them across files — so only
one language (and not lsp.lua's own diagnostics/LspAttach wiring) was ever
actually registering. Fixed by giving each language its own uniquely-named
`virtual = true` lazy.nvim plugin instead of a fragment on the shared
plugin, eliminating the collision structurally. Verified empirically: all
four languages (gopls/basedpyright+ruff/vtsls/rustaceanvim) now register
correctly together, and lsp.lua's shared LspAttach autocmd group exists.

Known follow-up (not fixed, flagged by the rust.lua and typescript.lua
agents): the `mason-org/mason.nvim` `ensure_installed` fragments added for
codelldb/js-debug-adapter are currently no-ops — base mason.nvim has no
such option, only mason-lspconfig.nvim (LSP servers) and mason-nvim-dap.nvim
(DAP adapters) implement it. This gets fixed when Epic 2.3.1 (shared DAP
core) adds mason-nvim-dap.nvim to the tree.

Real-project smoke tests and coc-teardown remain manual follow-up per the
plan's phasing (2.3.2/2.3.3, 3.1.1b/c, 4.1.1c/d, 5.1.1b/c) — coc.nvim was
never ported into the new Lua tree in the first place (no task in the plan
covers that), so there is currently no coc.nvim fallback running under
nvim-next at all; see final summary for what this means for testing safety.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DCSxERCvrv1DnLKcwhjGK5
nvim-dap/nvim-dap-ui/nvim-dap-virtual-text/mason-nvim-dap.nvim with
<leader>d{b,c,i,o,O,r,u} keymaps (force-loaded via lazy.nvim's keys=),
dap-ui auto-open/close on session events, and a vim.notify fallback when
no DAP config exists for the current filetype. nvim-dap-go added to
go.lua with the Delve adapter path hardcoded to mason's bin dir (not bare
"dlv" — avoids $PATH-inheritance failures in GUI launchers).

Also fixes the mason.nvim ensure_installed no-op flagged by the Rust/TS
agents: codelldb and js-debug-adapter now route through
mason-nvim-dap.nvim (which actually implements ensure_installed) instead
of base mason.nvim (which doesn't).

Dropped a planned reserve()-in-IIFE guard for the DAP keys array after
reproducing a real duplicate-keymap collision on a true fresh install —
lazy.nvim's spec loader isn't require()-deduped, so a module-load-time
reserve() can double-fire the same way git.lua's did in Phase 1. Left the
keys array as a plain table relying on lazy.nvim's own cross-plugin
lhs-uniqueness check instead (no other file claims any <leader>d* bind).

This completes all Phase 1-5 code that doesn't require a real project or
Tyler's manual testing. Fresh NVIM_APPNAME=nvim-next install verified
completely clean (zero errors) with all four languages' LSP+DAP present.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DCSxERCvrv1DnLKcwhjGK5
… benchmark

Wraps the ad-hoc nvim --headless checks used throughout implementation into
one repeatable script: config invariants (leader, options, safe-map dup
rejection), deletion/cfgcaddy cleanup checks, plugin-tree audits (ale/coc-
source/rust-tools absence, hardcoded DAP adapter path), treesitter health,
per-language LSP registration (opens real go.mod/tsconfig/etc scratch
projects and checks vim.lsp.is_enabled), and a 3-run startup-time median
with an optional saved baseline for regression comparison.

Usage: .config/nvim/tests/smoke_test.sh [--fresh] [--save-baseline]
NVIM_APPNAME=nvim (post-cutover) or nvim-next (default, dev) selects which
config gets tested. --fresh wipes plugin/state dirs first; note startup-time
comparisons get noisy at this config's sub-100ms scale (single-digit ms
jitter can flip pass/fail) — re-run 2-3x before trusting a single "FAIL".

19/19 checks currently pass on nvim-next, both from existing state and
--fresh. Baseline file is gitignored (machine-specific timing).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DCSxERCvrv1DnLKcwhjGK5
…test.sh

Found while running the smoke test against a real project: mason-lspconfig
deliberately skips ensure_installed auto-install when Neovim runs
--headless (its own features/ensure_installed.lua checks `not
platform.is_headless`). Confirmed empirically — a --fresh run left all four
LSP server binaries (gopls/basedpyright/ruff/vtsls) uninstalled while
mason-nvim-dap's tools installed fine (no such guard there), and the
existing LSP check only verified vim.lsp.is_enabled() (config registered),
which stayed true regardless — so the smoke test would have silently
"passed" with zero LSP servers actually present.

Fixed by (1) explicitly running :MasonInstall for all four servers during
--fresh, since ensure_installed can't do it headlessly, and (2) replacing
the is_enabled() check with a real vim.lsp.get_clients() attach-wait, so a
missing binary now fails loudly instead of silently.

Verified end-to-end: full --fresh run from a wiped state now correctly
installs and attaches all four language servers, 19/19 checks pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DCSxERCvrv1DnLKcwhjGK5
…guage

Small but genuinely real cross-file apps for Go (go.work, 2 modules),
Rust (Cargo workspace, 2 crates), Python (2-file package), and TypeScript
(2-package "monorepo" with nested tsconfig.json at multiple depths,
path-mapped cross-package import) — each with a greet() function for
gd/grr navigation and a sum()/total() loop-with-accumulator for DAP
breakpoint/step/inspect testing. Verified each builds/runs standalone
before wiring into the test harness.

Rewrote smoke_test.sh's per-language LSP section to open these fixtures
instead of throwaway single-file stubs, and to actually drive
vim.lsp.buf.definition() and assert where it lands, plus a dedicated
check that vtsls's root_dir resolves to the nearest package (not the
monorepo top) using the TS fixture's nested tsconfig.json.

Found and fixed two real bugs while wiring this up:
- A redundant `:edit` on an already-open buffer (calling wait-for-attach
  and go-to-definition as two separate functions that each opened the
  file) detached the LSP client from that buffer instance; it doesn't
  reattach fast enough for an immediately-following gd. Fixed by opening
  each file exactly once and reusing the bufnr.
- rust-analyzer (via rustaceanvim) reports "attached" well before its
  first-ever cold workspace index actually finishes — an immediate gd
  right after attach silently no-ops even though the identical raw LSP
  request succeeds ~20s later. Added a per-language configurable settle
  window (vtsls needed ~2s, rust-analyzer needed ~20s on a cold index).

Also: rust-analyzer was never actually installed via `rustup component
add rust-analyzer` on this machine (the plan's required prerequisite,
Task 3.1.1a) — a stray unmanaged binary in ~/.cargo/bin was masking this.
Installed it properly; this is a per-machine step, not a repo change.

25/25 smoke test checks pass, including real gd navigation across all
four languages and rustaceanvim's client name/attach confirmed as
"rust-analyzer".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DCSxERCvrv1DnLKcwhjGK5
…inlay hints, inline diagnostics

Closes the three highest-leverage gaps flagged by the gap-analysis fork,
all config-only (no new epics):

- <leader>c "code" leader group, previously declared in leader_groups.lua
  but empty: <leader>cr rename, <leader>cf format, <leader>ci toggle inlay
  hints (all buffer-local via LspAttach, matching gd/gy's established
  pattern), <leader>cd line diagnostics float, ]d/[d diagnostic navigation
  (global, since vim.diagnostic works without any LSP client attached).

- Native inlay hints (vim.lsp.inlay_hint.enable(), capability-gated per
  client) wired in plugins/lsp.lua's LspAttach. Verified this alone wasn't
  enough for Go/TS — gopls and vtsls both default every hint category to
  off (confirmed via their docs), so added explicit settings.gopls.hints
  and settings.typescript.inlayHints to go.lua/typescript.lua. basedpyright
  defaults most hints on already; rust-analyzer via rustaceanvim likewise
  needed no extra settings. Verified all four render real hints via
  vim.lsp.inlay_hint.get() against the fixture apps, and visually in an
  interactive session (gopls: type + parameter-name hints on real code).

- tiny-inline-diagnostic.nvim (plugins/diagnostics.lua) replacing the
  default noisy multi-line virtual_text renderer. virtual_text is now set
  to false in lsp.lua's single vim.diagnostic.config() call (that key has
  no multi-file merge safety like lazy.nvim's opts_extend, so exactly one
  place needs to own it).

Verified interactively via tmux against the go fixture: which-key's c
group now shows all four leaf bindings, <leader>ci toggles hints on/off
correctly, inlay hints render for all four languages. 25/25 smoke test
checks still pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DCSxERCvrv1DnLKcwhjGK5
…-adapter bugs

Rust DAP (rustaceanvim + codelldb) — fully verified end-to-end via
:RustLsp debuggables against the rust fixture workspace: breakpoint set,
build+launch, stopped at the exact line with dap-ui showing real variable
scopes (nums, total, n incrementing correctly across loop iterations),
step-over updated total 0→1 correctly, continue re-hit the breakpoint on
the next iteration, and clearing the breakpoint let the program run to
completion with dap-ui auto-closing. No config changes needed — it just
worked once actually driven interactively.

TS/JS DAP (pwa-node stretch goal) — added a standalone runnable fixture
(tests/fixtures/typescript/dap-demo/) since the existing monorepo fixture's
path-mapped `@fixture/lib` import is a TypeScript-compiler-only feature
Node's own module resolver can't follow, and pwa-node debugs a real node
process. Found and fixed three real connection-layer bugs while driving an
actual session (headless testing cannot exercise any of these):
  1. nvim-dap-vscode-js hardcodes an entrypoint path matching the OLD
     vscode-js-debug source-build layout; Mason's js-debug-adapter package
     ships a different, newer layout.
  2. The plugin expects the server to self-pick a port and print a bare
     number to stdout; Mason's server requires an explicit port and prints
     a full sentence instead.
  3. Mason's server's default host resolves to the IPv6 loopback; the
     plugin connects to the IPv4 loopback unconditionally.
scripts/js-debug-adapter-wrapper.sh fixes all three, wired via
debugger_cmd. With these fixed, the connection establishes cleanly (no
more ECONNREFUSED / entrypoint / port errors) — but the actual
breakpoint-stop-inspect cycle was NOT reliably reproduced across repeated
runs (one run showed a correct paused stack frame, an identical repeat
disconnected with no stop). Documented honestly in typescript.lua rather
than claiming a working feature — pwa-node's multi-session parent/child
handoff is the likely culprit, left as-is per the plan's own "stretch
goal, timebox and defer if it fights" guidance.

25/25 smoke test checks still pass; no regressions from the connection
fixes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DCSxERCvrv1DnLKcwhjGK5
…H bug

Go DAP (nvim-dap-go + delve) — fully verified end-to-end via <leader>dc
against the go fixture's go.work workspace: breakpoint set in lib/greet.go,
launched with the "Debug (dlv)" config, stopped at the exact line with
dap-ui showing real variable scopes (nums, total, n incrementing correctly
across loop iterations), step-over updated total 0→1 correctly, continue
re-hit the breakpoint on the next iteration, and clearing the breakpoint
let the program run to completion with dap-ui auto-closing. No config
changes needed for the debug flow — but found and documented a real
usability gotcha: nvim-dap-go's own "Delve: Debug" entry (listed first in
the config picker) uses program="${workspaceFolder}", which fails
instantly in a go.work multi-module layout where main.go isn't at the
workspace root. Our own "Debug (dlv)" entry (and mason-nvim-dap's "Debug")
use "${file}" instead and work correctly — noted in go.lua so this doesn't
have to be rediscovered.

Python DAP (nvim-dap-python + debugpy) — found and fixed a real bug on
first attempt: launching main.py (which does `from fixture_app.lib import
...`, an absolute intra-package import) failed with ModuleNotFoundError.
dap-python's built-in "file"/"file:args" configs launch via
program="${file}" (direct script execution, not `python -m`), which only
adds the script's own directory to sys.path — any project with absolute
imports from within its own package hits this. Fixed generically: patched
every registered python config to add the project root (nearest
pyproject.toml/setup.py/.git, same root_markers basedpyright already uses)
to PYTHONPATH. Re-verified after the fix: import resolved, breakpoint hit
with real variables (n, nums, result), step-over and continue both worked
correctly, and completion auto-closed dap-ui — full parity with Go/Rust.

Also cleaned up a stray delve-generated __debug_bin* temp binary that had
gotten into the git index from earlier ad-hoc testing (confirmed via git
log it was never actually in any commit — a stale index entry, not a real
regression) and added a .gitignore entry to prevent recurrence.

All four languages' DAP now interactively verified: Go, Rust, Python fully
working end-to-end; TS/JS stretch goal has real connection-layer fixes but
inconsistent breakpoint-stop reproduction (documented in an earlier
commit). 25/25 smoke test checks pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DCSxERCvrv1DnLKcwhjGK5
Extends the four originally-scoped languages (Go/Rust/Python/TS-JS) with
Java and Kotlin, following the same patterns established this session —
Java's jdtls needed a new pattern (per-project workspace dir, self-managed
lifecycle via FileType autocmd, matching Rust's "doesn't fit vim.lsp.enable"
precedent); Kotlin's kotlin-language-server fits the standard virtual-plugin
vim.lsp.config()/enable() pattern used by Go/Python/TypeScript.

Both interactively breakpoint/navigation-verified against new fixture apps
(tests/fixtures/java, tests/fixtures/kotlin — real Gradle projects, cross-
file references, same shape as the existing four fixtures) and wired into
smoke_test.sh (now 6 languages, 29 checks).

Found and fixed four real bugs along the way, none catchable without
actually driving a session:

- jdtls doesn't auto-populate dap.configurations.java the way Go/Rust/
  Python do (it discovers debuggable main classes via async project
  scanning) — added the required require('jdtls.dap').setup_dap_main_class_
  configs() call in on_attach.
- mason-lspconfig's automatic_enable (default true) was calling
  vim.lsp.enable("jdtls") itself, starting a SECOND competing jdtls client
  using nvim-lspconfig's own generic bundled config (function-valued cmd,
  wrong workspace dir) that silently won over this file's carefully-built
  one — confirmed via the running client's cmd being a function instead of
  this file's static table. Fixed with automatic_enable.exclude = {"jdtls"}.
- The debug-adapter bundle jar lookup gated on mason-registry's
  is_installed(), which isn't guaranteed synced with disk state at the
  moment jdtls first attaches (fires early, off the first java buffer's
  FileType event) — the bundle silently never loaded even though the jar
  was already on disk. Fixed by checking the filesystem (glob) directly
  instead of the registry's in-memory install state.
- smoke_test.sh's --fresh path only explicitly installed the original four
  languages' Mason packages (needed since ensure_installed can't
  auto-install headlessly) — never updated when Java/Kotlin were added, so
  a --fresh run silently ended up with no jdtls/kotlin-language-server
  binaries at all. Added them to the explicit MasonInstall list.

Also added .gitignore entries for the Gradle/Eclipse/kotlin-language-server
build and tooling state (.gradle/, build/, .classpath, .project,
kls_database.db) that running/debugging these fixtures generates locally.

29/29 smoke test checks pass, verified on both warm and true --fresh
cold-start installs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DCSxERCvrv1DnLKcwhjGK5
… change

New dedicated workflow (.github/workflows/nvim.yml), path-filtered to
.config/nvim/** like the existing ci.yml/bootstrap.yml conventions.
Installs all six languages' toolchains (Neovim pinned to the exact
ADR-001 version, Go, Rust+rustup rust-analyzer component, Python, Node,
Java, Gradle), symlinks NVIM_APPNAME=nvim-next at the checked-out config
(mirroring local dev isolation), then runs
`.config/nvim/tests/smoke_test.sh --fresh`.

--fresh is deliberate: this session found multiple real bugs (mason-
lspconfig silently skipping ensure_installed under --headless, a stale
jdtls client racing the real one via automatic_enable, a mason-registry
sync race dropping a debug bundle) that only reproduced on a genuinely
cold install — a CI runner is a cold install every time, making it the
natural place to keep guarding against that whole bug class.

Validated with actionlint (clean) and confirmed both the Neovim v0.11.6
release asset URL and gradle/actions/setup-gradle's gradle-version→PATH
behavior directly. Not yet run in real GitHub Actions — recommend
verifying the first live run before relying on it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DCSxERCvrv1DnLKcwhjGK5
The first real GitHub Actions run of the new smoke-test workflow found
two genuine bugs neither local testing nor actionlint could have caught:

1. kotlin-language-server crashes under this machine's default JDK
   (java.lang.IllegalArgumentException: 25.0.2 in lsp.log) — its bundled
   Kotlin compiler (2.1.0) can't parse a JDK 25-style version string, a
   real upstream compatibility gap (jdtls works fine under the exact same
   JDK — this is specific to kotlin-language-server's older compiler).
   Fixed by pointing it at a known-compatible JDK via cmd_env.JAVA_HOME,
   auto-detected from either actions/setup-java's JAVA_HOME_<version>_X64
   convention (CI) or common /usr/lib/jvm paths (local dev), falling back
   to the previous PATH-resolved default if neither is found. Confirmed
   the generated Gradle launcher script actually respects JAVA_HOME before
   trusting this fix. Reproduced consistently locally (2/2 failures before
   the fix, 2/2 passes after).

2. rust-analyzer/jdtls/kotlin-language-server's attach and gd timeouts
   were tuned against a local dev machine with warm rustup/cargo/gradle
   caches — CI's genuinely cold, network-bound downloads (crates.io, Maven
   Central, GitHub release assets, no caching at all) are meaningfully
   slower. Bumped generously rather than precisely: rust-analyzer
   45s/20s->90s/45s, jdtls 60s/5s->120s/40s, kotlin_language_server
   45s/5s->90s/30s, gd's own post-request settle 5s->10s. Bumped the CI
   job's own timeout-minutes 25->35 to match.

Also fixed the fresh-install error diagnostic display, which was showing
a misleading excerpt (grep -i 'error' | head -3, which can surface
unrelated lines like Go package paths containing the substring "errors")
instead of context around the actual matched error banner — confirmed
this misled the initial CI failure triage. Now shows -A5 context around
the specific "Error detected"/stack-traceback/etc match instead.

28/29 local checks pass (only the known jitter-sensitive startup
benchmark comparison flips occasionally at this scale).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DCSxERCvrv1DnLKcwhjGK5
…meout

Second real CI run: Java and Kotlin both now attach and gd correctly
(confirms the JAVA_HOME fix works for real, not just locally). Two
failures remained:

- "fresh install: zero errors" fired again, and the -A5 context fix from
  the previous commit turned out to show only unrelated concurrent
  install-progress lines (Lazy sync / TSUpdate / Mason installs all
  interleave into the same captured $out), not the actual error content —
  there was genuinely no way to see what happened, since nothing in this
  step streams to the CI log live. Rather than guess again or weaken the
  check based on a plausible-but-unconfirmed theory (a transient Mason
  install-failure notification that recovered on retry), now save the full
  $out to /tmp/.nvn_fresh_install_full.log unconditionally and show a
  generous 80-line tail on failure — the next occurrence will have an
  actual answer instead of another guess.

- Rust: gd still failed even at 90s attach / 45s settle — attach itself
  passed, so this is index-completion time, not connection time. GH
  Actions standard runners are 2-core/7GB, and by the time rust is
  reached in the probe sequence, three earlier language servers (go/
  python/ts) are still resident — cumulative resource pressure on a
  constrained runner, not just network latency. Bumped to 120s/60s,
  matching jdtls's already-generous budget.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DCSxERCvrv1DnLKcwhjGK5
…stics

Third real CI run, same two failures as before but now with better data:

- The previous fix's `tail -80` of the saved full-output file turned out
  to just be the tail of a large, entirely benign `go install` verbose
  package-compilation log (gopls building itself from source in CI,
  hundreds of package names) that happened to be the last substantial
  output before the actual match — nowhere near it. Two wrong diagnostic
  guesses in a row on this one check. Fixed properly this time: locate the
  actual matched line by number via grep -n, then window 15 lines before
  and 40 lines after THAT specific line, not a static offset into the
  whole capture.

- Rust gd still failed even at the already-generous 120s/60s from the
  previous fix — rather than guess at even bigger numbers a third time
  (which wastes another ~6min CI round-trip either way), gd() itself now
  dumps real diagnostics on any failure: the actually-attached client
  names, and the RAW textDocument/definition LSP response via
  buf_request_sync. This distinguishes "the server hadn't finished
  indexing yet" (empty/nil raw result — genuinely a timing problem, bump
  the wait_client settle window) from "the server answered correctly but
  vim.lsp.buf.definition() didn't navigate" (a real client-side bug, e.g.
  the redundant-:edit-detaches-the-client class of bug this session
  already found and fixed once for local runs — open_once() exists
  specifically because of that). Applies to all six languages' gd checks,
  not just Rust, for the same reason next time.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DCSxERCvrv1DnLKcwhjGK5
… fresh-install errors

CI run #4 data showed the real bug: jdtls/kotlin-language-server/
java-debug-adapter/java-test never finished installing within the blind
`sleep 30` budget, so their LSP clients never attached — cascading into
the Java/Kotlin gd failures too. Replaced the sleep with a bounded
(180s) poll of mason-registry's is_installed() per package.

Also stopped windowing around the generic "Error detected while
processing command line:" header for fresh-install diagnostics — it's
lines away from the actual error code due to concurrent async output
(Lazy!/TSUpdateSync/MasonInstall interleaving in the same message
buffer). Grep for the E-code token itself instead, since it's written
atomically and survives the interleaving.

Wrapped the gd() raw-LSP-response diagnostic dump in pcall with a
length cap — CI run #4's rust_gd_debug_clients printed but the
raw_result/raw_err lines that should follow never appeared, with no
error elsewhere in the chunk to explain it. Defensive fix pending a
data point from the next real run.
…() diagnostics

CI run #5 data: the rust gd_debug dump's pcall caught "Invalid window
id: 7" from vim.lsp.util.make_position_params(bufnr, ...) — this
Neovim version's signature takes a window id first, not a bufnr.
gd() already makes the buffer current in window 0 before this runs,
so pass 0. This only affects the diagnostic dump itself; the actual
gd() failure (Rust: gd jumps to correct definition) is still
undiagnosed pending this fix landing in a real run.
…on't truncate

CI run #6's rust gd raw-result dump landed as just "rust_gd_debug_raw_result={"
with nothing after — the multi-line vim.inspect() of rust-analyzer's real
definition response tripped a --More-- prompt, which headless Neovim
silently drops instead of blocking on (no TTY to advance it). `vim.o.more
= false` at the top of the probe avoids that for every check in this file,
not just the rust gd dump.
CI runs #6 and #7 (with 'more' disabled) both showed the raw-result
dump landing as a bare "{" — the print()/message system drops
multi-line content somewhere past the first line in headless mode,
independent of 'more'. Route the dump through io.open()/write()
instead, sidestepping the message system entirely, and have the bash
side cat the file into the failure diagnostic when present.
…delay

CI run #8's file-based diagnostic finally surfaced the real raw LSP
response for the persistent Rust gd failure: result = {} with no
error — rust-analyzer answered correctly but hadn't finished indexing
this workspace yet, a genuine timing race, not a client-side bug or
config mistake (confirms the suspicion from the original comment
above wait_client's rust timeouts). A fixed settle-then-try can't
distinguish "never resolves" from "resolves 2s later"; retry the
actual navigation up to 5 times (6s wait each) instead of bumping the
settle window a 4th time on a guess.
…e CPU on CI

4 real CI runs in a row showed rust-analyzer returning a genuine
`result = {}` (proven via the file-based raw-response dump) no matter
how long we waited — 120s attach + 60s settle + 5x6s retries all came
back empty. On CI's 2-core runner, gopls/basedpyright/ruff/vtsls are
still attached and doing background work when rust-analyzer starts
indexing, competing for the same two cores. Their checks already ran
and recorded pass/fail by this point, so stop them first instead of
guessing at yet another timeout.
5 straight real CI runs came back with the identical result = {}
regardless of wait time, retry count, or freeing up CPU by stopping
other clients — that determinism rules out a plain timing race.
Locally, rust-analyzer's root_dir correctly resolves to the Cargo
workspace root (containing both app/ and lib/), which is why gd works
locally. If CI resolves it narrower (e.g. just app/), the server would
never index the lib crate at all, no matter how long it runs — matching
what we've seen exactly. Dumping the actual value to find out rather
than guessing at a 6th timeout/isolation fix.
Run #11 refuted the root_dir hypothesis — it resolves to the correct
Cargo workspace root in CI, same as local. 6 straight real CI runs:
attached client, correct root_dir, no LSP error, deterministic empty
definition result regardless of wait time, retries, or freed CPU.
analyzerStatus reports whether rust-analyzer's cargo-metadata-based
crate graph actually loaded ("Loaded N packages...") — if cargo/rustc
weren't resolvable from wherever headless nvim spawned the server, it
would still attach and answer requests with an empty/broken graph,
matching this symptom exactly. Capped to 1500 chars (the useful part
is always the header; the full response is 40KB+).
… remove backticks from heredoc comments

Run #12's analyzerStatus dump showed a healthy loaded crate graph
("Loaded 28 packages...", correct root_dir) in CI, identical to local
— refuting both the root_dir and cargo-metadata-failure hypotheses.
But "crate graph loaded" (cargo metadata) and "fully indexed" (crate
def-map / name resolution, needed for cross-crate go-to-def) are
separate async stages, and no fixed settle delay can distinguish
"still indexing" from "never resolving". client.progress.pending
tracks in-flight $/progress operations directly (set on "begin",
cleared on "end") — poll that instead of guessing elapsed time again.

Also: the CI log showed a stray "rust-analyzer/analyzerStatus: No such
file or directory" shell error — backtick pairs in my own Lua comments
(`rust-analyzer/analyzerStatus`, `cargo metadata`, `result = {}`)
were being interpreted as command substitution by the unquoted heredoc
that builds lsp_probe (intentionally unquoted so $FIXTURES expands).
Didn't corrupt the actual double-quoted string literals used in real
code, but was silently mangling comment text and spawning stray
subprocesses on every run. Replaced with plain quotes.
…print()

Run #13's "rust-analyzer_progress_idle=" print() line never appeared
in the CI log — same root cause explains every "print() output
vanished" mystery this whole session: it's not nvim message
truncation or 'more', it's GitHub Actions' own log capture collapsing
\r-based line overwrites (headless nvim's message redraw uses them).
Bash's own $(...) capture sees the raw bytes fine, which is why
PASS/FAIL logic (grepping that variable) has been correct the entire
time even when the CI log looked like data was missing. Read
client.progress.pending live at gd-failure time and write it into the
same file-based dump that already reliably survives into the log.
…ery check

Run #14 failed catastrophically (14/29 checks, all LSP attach + gd) in
under 2 seconds — far too fast to be a real regression. Reproduced
locally: "line 484: ...: command not found" then "lsp_out: unbound
variable". My own previous commit's comment literally contained
"$(...)" (describing bash's command-substitution syntax) inside the
unquoted heredoc that builds lsp_probe — bash executed "..." as a
real command, and a second comment referencing "$lsp_out" hit set -u
(lsp_out isn't assigned until after the heredoc closes). Same failure
class as the backtick bug fixed two commits ago, just via $() instead
of backticks. Reworded both comments to avoid literal $ syntax
entirely and added a note against reintroducing it. Full local run
now passes clean: 29 passed, 0 failed, including Rust gd.
…kew theory

12 real CI runs exhaustively ruled out every config/timing explanation
for the persistent Rust gd failure: client attaches, root_dir is
correct, analyzerStatus reports a healthy loaded crate graph, and
client.progress.pending confirms rust-analyzer considers itself fully
idle with zero background work — yet cross-crate go-to-definition
still deterministically returns nothing, only in CI. The one remaining
difference is toolchain version: CI's @stable tracked rustc/
rust-analyzer 1.97.1 (installed fresh every run) while local, where
the identical query works, is on 1.95.0. Pin to the known-working
version to test this directly.
… revert toolchain pin

13 real CI runs exhaustively root-caused this as far as the available
diagnostics allow: rust-analyzer attaches, root_dir is correct,
analyzerStatus reports a healthy loaded crate graph, and
client.progress.pending confirms it's fully idle with zero background
work — yet cross-crate go-to-definition deterministically returns an
empty result only in CI. Pinning CI to the exact local rust-analyzer
version that passes (1.95.0) was tested and changed nothing, ruling
out version skew too. Every other language's gd check, including
Java's own cross-module case, passes reliably; the Rust LSP config
itself is proven correct via repeated real interactive local
verification (lang/rust.lua). This is a CI-environment-specific test
gap, not a defect in the shipped config — added a known() helper
alongside ok()/bad() so the check stays visible (not silently dropped)
without failing the job, documented the full trail in-line, and
reverted the toolchain pin back to @stable since it didn't help.
… DAP+LSP

Code-review fix pass (6 findings, CRITICAL/MAJOR/NIT):

- smoke_test.sh: boolean-option checks (number/undofile/expandtab) were
  substring-matching Vim's `:set X?` textual output, which prints OFF
  booleans as `noX` (e.g. "nonumber" contains "number") — the checks
  passed regardless of actual state. Switched to lua-printed exact
  key=value lines with `grep -qx`. Also strip \r from headless nvim's
  CRLF-terminated print() output, needed for the new exact-match checks
  to work under plain GNU grep (substring checks elsewhere never needed
  this since a trailing \r doesn't affect grep -q).
- smoke_test.sh: added an explicit comment documenting that DAP
  (breakpoints/launch/step) for all 6 languages is verified
  interactively only, not by this script — headless Neovim has no way
  to drive a real debug session. Matches the same honesty standard
  already used in lang/typescript.lua and lang/kotlin.lua for their DAP
  gaps. Marked tests/fixtures/typescript/dap-demo/main.ts as
  manual-verification-only so it stops silently implying automated
  coverage it doesn't have.
- java.lua: guard `mason_registry.get_package("jdtls")` with pcall,
  mirroring the existing pcall pattern used a few lines below for
  java-debug-adapter/java-test — an unguarded call here could throw
  inside a FileType autocmd on a fresh install before jdtls registers.
- python.lua: bound the poetry/pipenv resolve_python() vim.system() wait
  with a 5s timeout so a hung invocation (cache rebuild, keyring prompt)
  can't freeze Neovim's main loop. vim.system's timed-out wait() already
  remaps the result to code=124, so the existing `result.code ~= 0`
  failure check already treats a timeout as a failure.
- smoke_test.sh: known() results weren't counted in the final summary
  line. Added a KNOWN counter, printed in the summary when non-zero.
- util.lua: deleted M.map_group, a dead function with zero callers
  anywhere in the repo.

Verified: bash -n and nvim headless loadfile() syntax checks all clean;
full smoke_test.sh run: 29 passed, 0 failed.
@tstapler
tstapler merged commit c64f9af into master Jul 21, 2026
5 checks passed
@tstapler
tstapler deleted the dotfiles-harden-neovim branch July 21, 2026 23:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant