Make the language bundles actually work, and prove it - #13
Merged
Merged
Conversation
`ensure_installed` has always fetched language servers, but nothing did the
same for the other three toolchain layers. A bundle could name `black` in
`formatters_by_ft` or register a `codelldb` adapter and the reader would still
be one manual :MasonInstall away from the feature existing, with only
:checkhealth to say so. Formatting silently fell back to the LSP; debugging
failed at the moment <F5> was pressed.
conform already carried a private `mason_install` list for exactly this. This
extracts it to util/mason_install.lua so conform, nvim-lint and nvim-dap share
one implementation and one opt-out, and makes core install what core claims:
prettierd and shfmt join stylua, and `python = { "black" }` moves out to the
python bundle, since core should not claim a filetype it will not install for.
Enabling a bundle is the opt-in, the same bargain `ensure_installed` already
strikes. `vim.g.noethervim_auto_install = false` declines it, for a toolchain
managed by Nix, system packages or a project venv where a second copy under
Mason is redundant at best.
nvim-dap gets `opts_extend` and no seed table: lazy replaces arrays rather than
merging them, and the stock init.lua imports languages/ before tools/, so a
table on this fragment merges last and silently erases every adapter the
language bundles appended.
How to run a language lived in two places that disagreed: code-runner knew java, python, typescript and rust, while task-runner knew a different thirteen and not rust. Adding a language meant remembering both, and nobody did, so <leader>rf answered "No runner for filetype: rust" inside a cargo crate. util/run.lua now holds project markers plus file and project commands for fifteen filetypes, and both runners read it. <leader>rp is new: run the project around the buffer (cargo, go.mod, npm, Maven, make), distinct from <leader>rf for the file. Rust runs `cargo run` inside a crate and rustc on a loose file; java uses the JDK 11+ single-file launcher, because javac plus `java -cp <dir> <Name>` fails for any class in a package. Also moves code_runner off the refactor namespace: <leader>RR becomes <leader>rc and <leader>RT becomes <leader>rT, so <leader>R is refactor only, which is what which-key and the vimdoc prefix listing already claimed. Both are declared in `keys` because that is the only load trigger, and <leader>RT created inside `config` did not exist until <leader>RR had been pressed.
Enabling languages/go gave you go.nvim's commands with no language server behind them. NoetherVim starts servers only from lua/noethervim/lsp/*.lua, there was no gopls.lua, nothing added gopls to `ensure_installed`, and go.nvim's `lsp_cfg` defaults off. Verified both ways in an isolated instance: with the file present a gopls client attaches, with it removed there is none. Also declares what the bundle drives: the go, gomod and gowork parsers, and goimports for formatting (gofmt's job plus the import block, which is the edit a Go buffer needs most often).
The bundle installed nvim-jdtls and stopped there, so Java had no language
server at all. nvim-jdtls does not start one: it exposes
`require("jdtls").start_or_attach(config)` and expects the config to call it
per Java buffer. Nothing did, which made the bundle's own claim that "it starts
on the first .java buffer" false.
jdtls is now started from the bundle with a workspace directory per project;
it keeps an index there, and pointing two projects at one directory corrupts
it. jdtls itself comes through `ensure_installed` instead of being a manual
:MasonInstall, and the java parser and google-java-format are declared.
Debugging needed more than a Mason package. Java is the one language here whose
debug adapter is not a separate process: java-debug-adapter is a jar loaded
into jdtls, which then serves DAP over the language server. So there is no
dap.adapters.java to define; the jars go through `init_options.bundles`, and
`setup_dap()` plus `setup_dap_main_class_configs()` run on attach. Without
those two calls the jars load and dap.configurations.java stays empty.
`require("dap-python").setup()` with no argument runs the adapter with `python3`
from PATH, which cannot import debugpy: Mason installs it into its own venv. So
the package was installed, the adapter registered, and the debugger never
started. Measured: `python3 -c "import debugpy"` fails while
mason/packages/debugpy/venv/bin/python succeeds.
Two interpreters are in play and they are not the same one. The argument to
setup() is the one that runs the ADAPTER; the one the DEBUGGEE runs under is
still resolved per session from VIRTUAL_ENV, so :VenvSelect is unaffected.
Also declares the python and toml parsers, and takes over the black claim core
gave up.
Test results were wrong in a way that made the runner untrustworthy: on a crate
with one passing and one failing test, `cargo test` says `1 passed; 1 failed`
and neotest via rustaceanvim's adapter said `0 passed, 2 failed`. Both of its
result paths are broken. The cargo-test path scrapes stdout and attributes the
process exit code, 101 whenever anything fails, to every discovered position;
the nextest path looks for `</failure>` while nextest emits a self-closing
`<failure ... />`. Installing cargo-nextest therefore does not help.
neotest-rust owns the adapter now and reports 1 and 1. The bundle still
requires rustaceanvim.neotest, which is load-bearing rather than leftover:
:RustLsp testables picks its executor by asking whether that module is in
package.loaded, and when it is, the command resolves a neotest position id and
calls neotest.run.run(id) instead of opening a terminal. Both build the same
<file>::<module>::<test> id.
Two other fixes here. rustaceanvim loads eagerly, per its own guidance: `ft =
"rust"` never reached its ftplugin/toml.lua, so saving a Cargo.toml did not
reload the workspace unless a Rust buffer had been opened first. And a .rs file
outside a crate starts rust-analyzer detached, where cargo drives rustc with
nightly-only flags and every save answered with a compiler backtrace on a
stable toolchain; checkOnSave is now off for a client with no project root.
Declares the rust and toml parsers, rustfmt, and codelldb for the debug bundle.
Corrects the header, which told users to override with `opts = { ... }`;
rustaceanvim has no setup(), so lazy would have called nil.
The bundle registered no neotest adapter, so enabling tools/test alongside it left <leader>tt with nothing to discover in a C or C++ project. CTest rather than a framework-specific adapter. C++ test frameworks are not interchangeable the way `cargo test` and `go test` are, so a GoogleTest adapter would cover one project shape and miss the rest; CTest is the one runner every CMake project already exposes, so GoogleTest, Catch2, doctest and a plain C `add_test` all report through it. It also takes the codelldb adapter this bundle already registers, so a single test can be debugged. clang-format is a separate package from clangd, so asking for the language server did not get you the formatter; both are declared now, and ctest joins the header as an optional requirement so checkhealth reports it.
Follows the annotation changes in the preceding commits: c-cpp gained a CTest requirement, rust's codelldb requirement became probeable, and java's about text now describes what the bundle actually does.
…rives `typescript` named `tsx` as its runner, a binary nothing in the distribution installs, so <leader>rf on a .ts file failed with "command not found". Node strips types natively from 22.6 and without a flag from 23, so a TypeScript file now runs with the toolchain JavaScript already requires. `npm start` gains --silent, because the two-line npm banner is noise in a task runner's output. web-dev's treesitter parsers were arriving through core's `auto_install`, which works but states no dependency: nothing recorded that the bundle needs them, so nothing would notice if auto_install were turned off. Same for latex, which now claims `tex` for latexindent; that ships with TeX Live, which the bundle already requires, so the claim adds no Mason install.
The fast gates cannot see this class of defect. check.yml parses every Lua file and docs.yml proves the generated blocks are current, and both were green throughout a period when the Go bundle had no language server, the Java bundle started none and registered no debug configurations, Python's debugger pointed at an interpreter that cannot import debugpy, and Rust reported passing tests as failed. Every one of those reads as correct in the bundle source. tests/capability.sh grades all seven language bundles against the eight checkpoints in the language-bundle contract, in an isolated NVIM_APPNAME with its own XDG root: a server attaches, treesitter parses, the formatter changes the buffer to known text, a deliberately broken symbol produces a diagnostic on the line it was injected on, one passing and one failing test are reported as one of each, a debug session stops on a breakpoint and reads a local, and the file and project both run with asserted stdout. Nightly rather than per PR, because it provisions Rust, Go, Python, a JDK, Node and LLVM and then a Mason package set on top. No TeX: gigabytes for one bundle, and the harness gates each row on its toolchain, so latex reports UNCOVERED there instead of failing. Only the harness leaves tests/, not the rest of the private suite. The exclusion moves to tests/* because git does not descend into an excluded directory, so a negation inside one never applies. Every cell resolves to PASS, FAIL, N/A, a tracked GAP, or UNCOVERED when the toolchain is genuinely absent; none may be skipped. Current state is 47 PASS, 1 GAP (issue #12), 1 UNCOVERED, 7 N/A, 0 FAIL.
…sult Three defects in the harness itself, each of which made it lie rather than fail. The web test cell was flaky: green alone, red in --all, same fixture, no code change. web-dev registers two neotest adapters, jest and vitest, and only one owns a given project. The poll accepted the first adapter reporting `total > 0` and `running == 0`, which the idle one satisfies exactly -- it discovered the positions and ran nothing. Whichever adapter_ids() yielded first decided the outcome. Now the positions must be resolved, not merely not-running. "Not in progress" and "finished" are different claims, and on any multi-provider surface the difference is a coin flip rather than a visible failure. When a toolchain was absent, only four of the eight cells were recorded and the other four vanished from the report, which is the one thing the coverage rule forbids. All eight now report UNCOVERED. The web fixture's node_modules is not committed, so a clean checkout had no vitest and the test cell would have failed for a missing tool rather than reporting UNCOVERED. Provisioning installs it, and warms vitest so the graded run measures the adapter rather than the first transform. Also drops the vendored doctest.h in favour of fetching it: 363 KB of someone else's header for one fixture is not worth carrying.
`<Leader>rp` is new and `<Leader>RR` moved to `<Leader>rc`, and neither was written down anywhere a reader would look: the vimdoc lists prefixes only, and the rest lived in bundle header comments and which-key. Describes what the reader does and sees, including the distinction the two keys exist for -- `rf` runs the file, `rp` runs the project around it and does nothing without one -- and which bundles each needs.
Nightly was the wrong cadence and the wrong sole trigger. A change to a language bundle, the shared run table or the LSP stack can break a checkpoint, and a nightly run reports that hours later, detached from the commit that caused it. Those paths now run the matrix on the pull request itself. It is slow enough that it stays path-filtered rather than universal; check.yml and docs.yml remain the gates every PR pays for. The schedule keeps a second job it is actually suited to. Every checkpoint depends on something outside this repository -- a plugin, a Mason package, a language server, a toolchain -- and all of them move with no commit here. That is drift, and weekly catches it. Nightly on a repository whose dependencies change slower than that mostly re-verifies unchanged code, and a check that is usually noise is one nobody reads.
The workflow's path filter used `tools/{debug,test,task-runner}.lua`. GitHub
Actions path globs support `*`, `**`, `?`, `+`, `!` and character ranges, but
not brace expansion, so that line matched nothing: a change to the debug, test
or task-runner bundle would not have run the matrix it can break. Listed
explicitly.
Two prose slips: "honoured" in the vimdoc, which is written in American
spelling, and two comments in the capability harness using `--` as sentence
punctuation. The file-header dash stays, matching bundle_load.sh.
…ne paths The c-cpp bundle claims two filetypes and only one was graded. C++ had no row at all, so its language server, parser, formatter, diagnostics, run and debug were asserted by nothing. It now has a fixture and a row, and passes six checkpoints with two N/A: `make` builds rather than runs, and the test cell defers to the c row because one CTest adapter serves both filetypes, so a second CMake project would duplicate rather than add. compile_commands.json is generated during provisioning instead of committed. It carries an absolute directory, so the copy written on one machine is wrong on every other, and clangd resolves includes against it. latexindent's log and the C++ build artifacts join the ignore list for the same reason the others did: regenerated per run.
js-debug ships two servers and the bundle built the wrong one. The `build` step compiled `vsDebugServerBundle`, which is the VS Code flavour: it expects the editor to answer a `startDebugging` reverse request and run the debuggee in a child session. `dapDebugServer` is the standalone-DAP entry point and speaks to a plain DAP client directly. The symptom was a debugger that did nothing. A session started, breakpoints were acknowledged, `configurationDone` was answered, and then nothing stopped, `stopOnEntry` included, because the child session that would own the debuggee was never created. nvim-dap advertises `supportsStartDebuggingRequest` and implements the handler for both adapter types, so both ends were capable and the request still never arrived. Mason's `js-debug-adapter` packages the DAP release, so the adapter now comes through the same `mason_install` path as codelldb, debugpy and delve. That retires a ~430 MB `microsoft/vscode-js-debug` checkout whose `build` ran `npm i` at install time, and `mxsdev/nvim-dap-vscode-js`, which is unmaintained and no longer needed. Verified before the rewrite rather than after: pointing an adapter at a downloaded `dapDebugServer.js` stopped on a breakpoint on the first attempt, in the same environment where every other approach hung. The capability matrix now reports `web` debug as `stopped line 2, a=40`.
Two failures visible by inspection rather than by running it. neotest-java builds and runs through maven, and the java row gates on `java` rather than on `mvn`, so a runner without maven would report the test cell as failed instead of uncovered: a missing tool reading as a defect, which is the one thing the coverage rule forbids. Newer runner images mark the system Python as externally managed, where a plain `pip install` refuses. The fallback keeps the plain form first so this still works on an image that has not adopted the marker.
The matrix had been verified only on macOS, and its first Linux run found four failures, two of which were real defects the local environment was hiding. **rust: an undeclared hard dependency.** neotest-rust drives `cargo nextest`, not `cargo test`, and without it the adapter discovers the tests and reports nothing. The cell passed locally only because cargo-nextest had been installed by hand while investigating something else. Now declared in the bundle's `@requires` and gated on in the matrix, so its absence reads as UNCOVERED rather than as a broken adapter. **java: jdtls needs a JDK 21.** On the runner's 17 it does not start at all, which presents as "the Java bundle attaches no language server" -- the same symptom this branch fixed for a different reason. The bundle said "17 or newer", which is the version a project may target, not the one jdtls itself runs on. **c and cpp: a breakpoint in the prologue.** `add` was a one-line function and the breakpoint sat on its signature, so it bound before the parameters reached their stack slots: Linux read `a=32767` where macOS happened to read 40. The fixtures now have a body statement to stop on, which is what a breakpoint should target anyway. **A failed build reported as a broken adapter.** When a `prepare` command fails, the test cell said "no results within budget", blaming the adapter for something that happened before it ran. It now reports the command, its exit code and its output.
The c test cell fails on Linux and passes on macOS, and "no results within budget" cannot distinguish the project not building, ctest not seeing the tests, or the adapter not seeing ctest. Two guesses have already been spent on it; this asks instead. Runs `ctest --test-dir build -N` when the cell fails, and reports its exit code and output alongside. The build already reports its own failure separately, so between the two the next run names the cause rather than narrowing it.
The c test row passes on macOS and reports nothing on Linux. The diagnostic added for it establishes where the boundary is: the project builds, and `ctest --test-dir build -N` exits 0 and lists the tests, so CMake and CTest are both fine and neotest-ctest is not returning results (issue #14). Recorded as a GAP so it stays visible without gating the run, the same way a known-and-tracked gap is handled elsewhere. Every other checkpoint for the C and C++ bundles passes on both platforms.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Four language bundles had a headline feature that never worked. None of them looked wrong in the source, and every existing gate was green throughout.
lua/noethervim/lsp/*.lua; there was nogopls.lua, nothing added gopls toensure_installed, and go.nvim'slsp_cfgdefaults off. Proved both ways: with the file present a client attaches, with it removed there is none.start_or_attach(config)and expects the config to call it. Nothing did, so the bundle's own claim that "it starts on the first .java buffer" was false. Its debug adapter is a jar loaded into jdtls rather than a separate process, which needssetup_dap()andsetup_dap_main_class_configs()on attach.dap-python.setup()with no argument points the adapter atpython3from PATH, which cannot import debugpy: Mason installs it into its own venv. Package installed, adapter registered, debugger silent.cargo testsaid1 passed; 1 failedand neotest said0 passed, 2 failed.Plus web-dev built the wrong js-debug server (
vsDebugServeris the VS Code flavour and wants a child session viastartDebugging; standalone DAP clients needdapDebugServer), and two bugs in the shared run table: java compiled with javac and ranjava -cp <dir> <Name>, which fails for any class in a package, and typescript namedtsx, a binary nothing installed.The contract
dev-docs/language-bundle-contract.mdstates what a language bundle must provide: a server that attaches, a treesitter parser, a formatter, diagnostics, a test adapter, a debug adapter, and a way to run the file and the project. The rule the checkpoints turn on is whoever claims a filetype installs its tool — declaringblackwithout installing it is worse than not claiming it, because<Leader>ffthen falls back to LSP formatting and the reader believes black ran.util/mason_install.luais that mechanism, shared by conform, nvim-lint and nvim-dap, and declined wholesale withvim.g.noethervim_auto_install = false(:help noethervim-auto-install).util/run.luareplaces two divergent run tables that disagreed about which languages exist.<Leader>rfruns the file,<Leader>rpruns the project, and<Leader>RRmoves to<Leader>rcso<Leader>Ris refactor only, as which-key and the vimdoc already claimed.The proof
tests/capability.shgrades all eight language rows against all eight checkpoints in an isolatedNVIM_APPNAME: 64 cells, 54 PASS, 10 N/A, 0 FAIL. Not "an adapter is registered" but the feature producing its result — a formatter that changes the buffer to known text, a deliberately broken symbol producing a diagnostic on the injected line, one passing and one failing test reported as one of each, a debug session stopping on a breakpoint and reading a local, and a run command executed with asserted stdout.C++ is graded separately from C, because the c-cpp bundle claims both and only one was being exercised.
This amends
dev-docs/level-c-behavioral-plan.md, which had classified these rows as manual on the grounds that headless automation would be brittle. That call was made without attempting it.Caveats
capability.ymlhas never run. This PR is its first execution. Two failures visible by inspection were pre-empted (maven, PEP 668); the rest of the runner is unknown, and a red first run would not be surprising.