Skip to content

Make the language bundles actually work, and prove it - #13

Merged
Chiarandini merged 20 commits into
mainfrom
fix/bundle-sweep-fixes
Sep 1, 2026
Merged

Chiarandini merged 20 commits into
mainfrom
fix/bundle-sweep-fixes

Conversation

@Chiarandini

Copy link
Copy Markdown
Owner

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.

  • Go had no language server at all. 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. Proved both ways: with the file present a client attaches, with it removed there is none.
  • Java had no language server either, and no debug configurations. nvim-jdtls does not start jdtls; it exposes 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 needs setup_dap() and setup_dap_main_class_configs() on attach.
  • Python's debugger never started. dap-python.setup() with no argument points the adapter at python3 from PATH, which cannot import debugpy: Mason installs it into its own venv. Package installed, adapter registered, debugger silent.
  • Rust reported passing tests as failed. Both of rustaceanvim's result paths mis-parse; cargo test said 1 passed; 1 failed and neotest said 0 passed, 2 failed.

Plus web-dev built the wrong js-debug server (vsDebugServer is the VS Code flavour and wants a child session via startDebugging; standalone DAP clients need dapDebugServer), and two bugs in the shared run table: java compiled with javac and ran java -cp <dir> <Name>, which fails for any class in a package, and typescript named tsx, a binary nothing installed.

The contract

dev-docs/language-bundle-contract.md states 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 — declaring black without installing it is worse than not claiming it, because <Leader>ff then falls back to LSP formatting and the reader believes black ran.

util/mason_install.lua is that mechanism, shared by conform, nvim-lint and nvim-dap, and declined wholesale with vim.g.noethervim_auto_install = false (:help noethervim-auto-install).

util/run.lua replaces two divergent run tables that disagreed about which languages exist. <Leader>rf runs the file, <Leader>rp runs the project, and <Leader>RR moves to <Leader>rc so <Leader>R is refactor only, as which-key and the vimdoc already claimed.

The proof

tests/capability.sh grades all eight language rows against all eight checkpoints in an isolated NVIM_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.yml has 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.
  • Everything here was verified on macOS. The workflow is what starts answering Linux.
  • Four harness bugs were found and fixed along the way, one of which made a cell flaky rather than red: it accepted an idle neotest adapter's counts, because "not in progress" is not the same claim as "finished".

`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.
@Chiarandini
Chiarandini merged commit 238ee37 into main Sep 1, 2026
5 checks passed
@Chiarandini
Chiarandini deleted the fix/bundle-sweep-fixes branch September 1, 2026 07:31
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