diff --git a/.github/workflows/capability.yml b/.github/workflows/capability.yml new file mode 100644 index 0000000..57d0d5c --- /dev/null +++ b/.github/workflows/capability.yml @@ -0,0 +1,138 @@ +name: capability matrix + +# Grades every language bundle against the eight checkpoints in the +# language-bundle contract: a language server attaches, treesitter parses, +# the formatter reformats, diagnostics appear, tests run, the debugger stops on +# a breakpoint, and the file and project both run. +# +# Two triggers, because there are two ways this can break. +# +# A change to a language bundle, to the shared run table, or to the LSP stack +# can break a checkpoint, so those paths run it on the pull request that made +# the change -- attributed to that change, while the author is looking. It is +# slow (it provisions Rust, Go, Python, a JDK, Node and LLVM, then a Mason +# package set), which is why it is path-filtered rather than universal: +# check.yml and docs.yml stay the fast gates every PR pays for. +# +# The matrix can also break with no commit at all. Every checkpoint depends on +# something outside this repository -- a plugin, a Mason package, a language +# server, a toolchain -- and all of them move. That is what the schedule is +# for, and weekly is the honest cadence for it: nightly on a repository whose +# dependencies change slower than that mostly re-verifies unchanged code and +# trains you to ignore the result. +on: + pull_request: + paths: + - "lua/noethervim/bundles/languages/**" + - "lua/noethervim/bundles/tools/debug.lua" + - "lua/noethervim/bundles/tools/test.lua" + - "lua/noethervim/bundles/tools/task-runner.lua" + - "lua/noethervim/util/run.lua" + - "lua/noethervim/util/mason_install.lua" + - "lua/noethervim/plugins/lsp.lua" + - "lua/noethervim/lsp/**" + - "tests/capability*" + - "tests/fixtures/lang/**" + - ".github/workflows/capability.yml" + schedule: + # Mondays 04:00 UTC: a drift canary, not a per-change gate. + - cron: "0 4 * * 1" + workflow_dispatch: + inputs: + language: + description: "One language (rust, go, python, c, java, web, latex) or --all" + required: false + default: "--all" + +concurrency: + group: capability-${{ github.ref }} + cancel-in-progress: true + +jobs: + matrix: + name: language capability matrix + runs-on: ubuntu-latest + # The budget is dominated by provisioning, not by the checks. + timeout-minutes: 60 + + steps: + - uses: actions/checkout@v5 + + - name: Install Neovim + uses: rhysd/action-setup-vim@v1 + with: + neovim: true + version: stable + + # Each language brings its own toolchain. A missing one is reported as + # UNCOVERED rather than FAIL, so a provisioning failure here degrades the + # run honestly instead of turning it red for the wrong reason -- but the + # point of installing them is that UNCOVERED should stay empty. + - name: Rust toolchain + run: | + rustup component add rust-analyzer rustfmt + # neotest-rust drives `cargo nextest`, not `cargo test`. + curl -fsSL https://get.nexte.st/latest/linux \ + | tar zxf - -C "${CARGO_HOME:-$HOME/.cargo}/bin" + + - uses: actions/setup-go@v5 + with: { go-version: "stable" } + + - uses: actions/setup-python@v5 + with: { python-version: "3.12" } + + - uses: actions/setup-java@v4 + # 21, not 17: jdtls itself runs on 21, and on 17 it does not start at + # all, which reads as "the Java bundle attaches no language server". + with: { distribution: temurin, java-version: "21" } + + - uses: actions/setup-node@v4 + with: { node-version: "22" } + + - name: C, CMake, Maven and the rest + run: | + sudo apt-get update + # maven is what neotest-java builds and runs through; the java row's + # test cell needs it, and the toolchain gate is on `java`, so its + # absence would read as a failure rather than as UNCOVERED. + sudo apt-get install -y build-essential cmake clangd maven + # The tree-sitter CLI is what nvim-treesitter builds parsers with, + # and core disables parser installation without it. Not an apt + # package; npm is the supported route. + npm install -g tree-sitter-cli + # PEP 668 marks the runner's Python as externally managed, so a plain + # `pip install` fails there. Try it first anyway, so this keeps + # working on an image that has not adopted the marker. + python3 -m pip install --quiet pytest \ + || python3 -m pip install --quiet --break-system-packages pytest + + - name: Run the matrix + # The harness owns its own isolated NVIM_APPNAME and XDG root, so it + # cannot touch a real config even on a runner. + # + # No TeX distribution is installed: it is gigabytes for one bundle, and + # the harness gates every row on its toolchain, so latex reports + # UNCOVERED here rather than failing. Its one real capability, the + # compile, is covered by tests/behave_latex.lua. + # + # `pipefail` so a crash in the harness fails the step even though the + # output is piped through tee. + shell: bash + run: | + set -o pipefail + bash tests/capability.sh "${{ inputs.language || '--all' }}" 2>&1 | tee capability.log + + # The matrix is the artifact worth keeping: it is the record of what the + # distribution could actually do on a given day. + - name: Publish the matrix + if: always() + run: | + { + echo '## Language capability matrix' + echo + echo '| language | checkpoints |' + echo '| --- | --- |' + grep '^MATRIX' capability.log 2>/dev/null \ + | sed 's/^MATRIX \([a-z]*\) /| `\1` | /; s/$/ |/' \ + || echo '| (no matrix rows; see the job log) | |' + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.gitignore b/.gitignore index 832df79..2e7bae4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,44 @@ lazy-lock.json -tests/ + +# tests/ is private, EXCEPT the language capability harness. That is maintainer +# infrastructure rather than the user-facing suite the rest of tests/ holds: it +# grades every language bundle against the eight checkpoints in +# dev-docs/language-bundle-contract.md, and the nightly workflow has to be able +# to check it out and run it. +# +# The exclusion is on tests/* rather than tests/, because git does not descend +# into an excluded directory and a negation inside one never applies. +tests/* +!tests/capability.sh +!tests/capability_assert.lua +!tests/capability_provision.lua +!tests/fixtures/ +tests/fixtures/* +!tests/fixtures/lang/ + +# Fixture build output: regenerated by the harness on every run, and large. +tests/fixtures/lang/*/target/ +tests/fixtures/lang/*/build/ +tests/fixtures/lang/*/node_modules/ +tests/fixtures/lang/*/__pycache__/ +tests/fixtures/lang/*/.pytest_cache/ +tests/fixtures/lang/*/package-lock.json +tests/fixtures/lang/c/main +tests/fixtures/lang/c/main_debug +tests/fixtures/lang/cpp/main +tests/fixtures/lang/cpp/main_debug +tests/fixtures/lang/go/capfixture +tests/fixtures/lang/rust/Cargo.lock +# latexindent drops a log beside whatever it formats. +tests/fixtures/lang/latex/indent.log +# Generated per run: it embeds an absolute directory. +tests/fixtures/lang/*/compile_commands.json +tests/fixtures/lang/c/doctest.h +tests/fixtures/lang/*/*.dSYM/ +# jdtls writes an Eclipse project into whatever it opens. +tests/fixtures/lang/java/.classpath +tests/fixtures/lang/java/.project +tests/fixtures/lang/java/.settings/ .DS_Store .cache .claude/ diff --git a/README.md b/README.md index ea8eb0f..e2b9965 100644 --- a/README.md +++ b/README.md @@ -123,7 +123,7 @@ header. - `c-cpp` needs compile_commands.json - `go` needs Go toolchain -- `java` needs a JDK +- `java` needs a JDK 21 or newer - `latex` needs latexmk - `python` needs Python 3 - `rust` needs rust-analyzer, Cargo diff --git a/doc/noethervim.txt b/doc/noethervim.txt index f06eb0d..abe9be7 100644 --- a/doc/noethervim.txt +++ b/doc/noethervim.txt @@ -1203,6 +1203,28 @@ can be turned off with a single flag in your init.lua, set before With the flag set, `nvim` opens straight into an empty buffer. Set it to `true` or leave unset to keep the dashboard (default). + *noethervim-auto-install* +Declining toolchain auto-install: ~ + +Enabling a bundle is how you ask for the tools it drives. A language +bundle names its language server, formatter, linter and debug adapter, +and NoetherVim fetches through Mason whatever is missing, the same way +language servers have always arrived. Enabling `languages/rust` together +with `tools/debug` gets you codelldb; `languages/python` gets you black. + +To decline, set this in your init.lua before `lazy.setup`: >lua + vim.g.noethervim_auto_install = false +< +Nothing is then fetched on your behalf. Bundles still declare what they +need, `:checkhealth noethervim` still names anything missing, and +`:Mason` still installs on request. Set this when a toolchain is managed +outside the editor, by Nix, by system packages, or by a project-local +environment, where a second copy under Mason is at best redundant and at +worst a version you did not choose. + +Language servers are unaffected: they arrive through `ensure_installed` +and are not gated by this flag. + ============================================================================== 9. KEYMAP NAMESPACES *noethervim-keymaps* *neothervim-keymaps* @@ -1294,6 +1316,28 @@ Prefixes: ~ every buffer it can before forcing out. `ZR` (scratch sweep) deletes unnamed scratch buffers. + *noethervim-running* +Running code: ~ + + `rf` Run the current file + `rp` Run the project around it + `rc` Run the current file in a floating window + `rT` Send the run command to a betterTerm terminal + + What each language runs is one table, so the four agree. `rf` and `rp` + need the task-runner bundle; `rc` and `rT` are core, and `rT` also + needs the better-term bundle. + + The difference between `rf` and `rp` is the project: in a Cargo crate + `rf` runs `cargo run`, and on a loose .rs file it compiles that file + alone. `rp` runs the project's own entry point (cargo, go.mod, npm, + Maven, make) and does nothing when there is no project around the + buffer. Interpreted languages have `rf` only. + + Version managers are honored: an interpreter is resolved through + mise, asdf, pyenv, rbenv, nodenv or goenv for the buffer's directory + before it runs, so a project pinned to an older Python gets that one. + Other notable keymaps: ~ Normal: ~ diff --git a/doc/tags b/doc/tags index 73b3f67..b448b30 100644 --- a/doc/tags +++ b/doc/tags @@ -6,6 +6,7 @@ neothervim-install noethervim.txt /*neothervim-install* neothervim-keymaps noethervim.txt /*neothervim-keymaps* noethervim noethervim.txt /*noethervim* noethervim-Z noethervim.txt /*noethervim-Z* +noethervim-auto-install noethervim.txt /*noethervim-auto-install* noethervim-brackets noethervim.txt /*noethervim-brackets* noethervim-bundle-toggle noethervim.txt /*noethervim-bundle-toggle* noethervim-bundles noethervim.txt /*noethervim-bundles* @@ -49,6 +50,7 @@ noethervim-picker-notifications noethervim.txt /*noethervim-picker-notifications noethervim-q-close noethervim.txt /*noethervim-q-close* noethervim-quickstart noethervim.txt /*noethervim-quickstart* noethervim-resize-arrows noethervim.txt /*noethervim-resize-arrows* +noethervim-running noethervim.txt /*noethervim-running* noethervim-semicolon noethervim.txt /*noethervim-semicolon* noethervim-shadowed noethervim.txt /*noethervim-shadowed* noethervim-snippet-stop noethervim.txt /*noethervim-snippet-stop* diff --git a/docs-site/src/content/docs/guides/bundles.md b/docs-site/src/content/docs/guides/bundles.md index b4d3212..764d544 100644 --- a/docs-site/src/content/docs/guides/bundles.md +++ b/docs-site/src/content/docs/guides/bundles.md @@ -24,7 +24,7 @@ reads. Run that for the state of the bundles you actually have enabled. ### `c-cpp` -Installs clangd on demand and adds the C and C++ treesitter parsers. With the debug bundle also enabled, registers the codelldb adapter and launch configurations for both languages. +Installs clangd on demand and adds the C and C++ treesitter parsers. With the debug bundle also enabled, registers the codelldb adapter and launch configurations for both languages; with the test bundle, runs CTest through neotest, whatever framework the project uses. ```lua { import = "noethervim.bundles.languages.c-cpp" } @@ -36,6 +36,8 @@ Requires:
CMake writes it with CMAKE_EXPORT_COMPILE_COMMANDS=ON; Make users usually generate it with bear - **codelldb** *(optional)*: stepping through C and C++, when the debug bundle is also enabled
:MasonInstall codelldb +- **CTest** *(optional)*: running tests, when the test bundle is also enabled; ships with CMake +
https://cmake.org/download/ (CMake 3.21 or newer) ### `go` @@ -54,7 +56,7 @@ Requires: ### `java` -jdtls needs workspace management and jar paths that plain lspconfig cannot supply, so it gets a dedicated client. It starts on the first .java buffer. Install the server itself with :MasonInstall jdtls. With the test bundle also enabled, registers the JUnit adapter. +jdtls needs workspace management and jar paths that plain lspconfig cannot supply, so it gets a dedicated client, started per buffer with a workspace directory of its own. With the debug bundle also enabled it loads the Java debug and test jars into the server, which is what makes breakpoints and the JUnit adapter work. ```lua { import = "noethervim.bundles.languages.java" } @@ -62,8 +64,8 @@ jdtls needs workspace management and jar paths that plain lspconfig cannot suppl Requires: -- **a JDK**: jdtls will not start without one -
JDK 17 or newer; jdtls itself installs via Mason +- **a JDK 21 or newer**: jdtls itself runs on 21; the project it indexes may target older +
https://adoptium.net/ - **Maven or Gradle** *(optional)*: neotest-java builds and runs through the project's own tool, when the test bundle is also enabled
whichever your project already uses @@ -103,7 +105,7 @@ Requires: ### `rust` -rustaceanvim goes past plain rust-analyzer with macro expansion, runnables and debuggables, the crate graph, hover actions and structural search-replace. It manages its own LSP client, so no lspconfig entry is needed. With the test bundle also enabled, it supplies its own neotest adapter. +rustaceanvim goes past plain rust-analyzer with macro expansion, runnables and debuggables, the crate graph, hover actions and structural search-replace. It manages its own LSP client, so no lspconfig entry is needed. With the test bundle also enabled, cargo tests run through neotest; with the debug bundle, it loads debug targets from rust-analyzer once an adapter is installed. ```lua { import = "noethervim.bundles.languages.rust" } @@ -115,8 +117,10 @@ Requires:
rustup component add rust-analyzer - **Cargo**: building and running from the editor
https://rustup.rs/ -- **codelldb or lldb** *(optional)*: stepping through Rust, when the debug bundle is also enabled -
:MasonInstall codelldb +- **cargo-nextest** *(optional)*: running tests, when the test bundle is also enabled; neotest-rust drives nextest rather than cargo test +
cargo install cargo-nextest +- **codelldb** *(optional)*: stepping through Rust, when the debug bundle is also enabled +
:MasonInstall codelldb, or put lldb-dap on PATH ### `web-dev` @@ -252,7 +256,7 @@ Requires: ### `task-runner` -overseer.nvim runs and tracks tasks, compiler.nvim wraps it in a project compiler UI, and <leader>rf runs the current file in a way that respects filetype and version managers. +overseer.nvim runs and tracks tasks, compiler.nvim wraps it in a project compiler UI, and <leader>rf and <leader>rp run the current file or the project around it, respecting filetype, project markers and version managers. ```lua { import = "noethervim.bundles.tools.task-runner" } diff --git a/init.lua.example b/init.lua.example index 0831922..d5dfcd3 100644 --- a/init.lua.example +++ b/init.lua.example @@ -30,6 +30,12 @@ vim.g.mapsearchleader = "" -- search/navigation prefix (default: . + opts.mason_install = opts.mason_install or {} + table.insert(opts.mason_install, "codelldb") + local dap = require("dap") dap.adapters.codelldb = { @@ -94,4 +115,30 @@ return { end end, }, + + -- ── C / C++ test adapter ────────────────────────────────────────────── + -- `optional = true` gates this on tools/test.lua, like every other + -- language bundle. + -- + -- CTest rather than a framework-specific adapter: a GoogleTest adapter + -- only sees GoogleTest, and C++ test frameworks are not interchangeable + -- the way `cargo test` and `go test` are. CTest is the one runner every + -- CMake project already exposes, so GoogleTest, Catch2 and doctest all + -- report through the same path, and a plain C project registering + -- `add_test` works too. + -- + -- The dap strategy launches the test binary directly under codelldb, + -- which is the adapter the fragment above registers, so `td` + -- debugs a single test. + { + "nvim-neotest/neotest", + optional = true, + dependencies = { "orjangj/neotest-ctest" }, + opts = function(_, opts) + opts.adapters = opts.adapters or {} + table.insert(opts.adapters, require("neotest-ctest").setup({ + dap_adapter = "codelldb", + })) + end, + }, } diff --git a/lua/noethervim/bundles/languages/go.lua b/lua/noethervim/bundles/languages/go.lua index 9cfd891..66da2c8 100644 --- a/lua/noethervim/bundles/languages/go.lua +++ b/lua/noethervim/bundles/languages/go.lua @@ -34,6 +34,32 @@ -- below. return { + -- gopls is not installed by go.nvim (its `lsp_cfg` defaults to off), and + -- core's list does not carry it, so without this the Go bundle gives you + -- tooling with no language server behind it. The matching + -- `vim.lsp.enable` lives in lua/noethervim/lsp/gopls.lua. + { "neovim/nvim-lspconfig", + opts = function(_, opts) + opts.ensure_installed = opts.ensure_installed or {} + vim.list_extend(opts.ensure_installed, { "gopls" }) + end, + }, + + { "nvim-treesitter/nvim-treesitter", + opts = { ensure_installed = { "go", "gomod", "gowork" } }, + }, + + -- goimports over gofmt: it does gofmt's job and fixes the import block, + -- which is the edit a Go buffer needs most often. + { "stevearc/conform.nvim", + opts = function(_, opts) + opts.formatters_by_ft = opts.formatters_by_ft or {} + opts.formatters_by_ft.go = { "goimports" } + opts.mason_install = opts.mason_install or {} + table.insert(opts.mason_install, "goimports") + end, + }, + { "ray-x/go.nvim", dependencies = { @@ -65,6 +91,12 @@ return { opts = {}, }, }, + -- nvim-dap-go looks for `dlv` on PATH; Mason's `delve` package puts it + -- there, so the adapter it registers has something to launch. + opts = function(_, opts) + opts.mason_install = opts.mason_install or {} + table.insert(opts.mason_install, "delve") + end, }, -- ── Go test adapter ─────────────────────────────────────────────────── diff --git a/lua/noethervim/bundles/languages/java.lua b/lua/noethervim/bundles/languages/java.lua index 98a738c..ae79149 100644 --- a/lua/noethervim/bundles/languages/java.lua +++ b/lua/noethervim/bundles/languages/java.lua @@ -1,11 +1,13 @@ ---@bundle java ---@desc Java language server with proper workspace handling ---@about jdtls needs workspace management and jar paths that plain lspconfig ---- cannot supply, so it gets a dedicated client. It starts on the first ---- .java buffer. Install the server itself with :MasonInstall jdtls. ---- With the test bundle also enabled, registers the JUnit adapter. ----@requires exe=java label="a JDK" why="jdtls will not start without one" ---- install="JDK 17 or newer; jdtls itself installs via Mason" +--- cannot supply, so it gets a dedicated client, started per buffer with +--- a workspace directory of its own. With the debug bundle also enabled +--- it loads the Java debug and test jars into the server, which is what +--- makes breakpoints and the JUnit adapter work. +---@requires exe=java label="a JDK 21 or newer" +--- why="jdtls itself runs on 21; the project it indexes may target older" +--- install="https://adoptium.net/" ---@requires note="Maven or Gradle" --- why="neotest-java builds and runs through the project's own tool, --- when the test bundle is also enabled" @@ -13,32 +15,137 @@ -- NoetherVim bundle: Java -- Enable with: { import = "noethervim.bundles.languages.java" } -- --- Provides nvim-jdtls -- proper Java LSP support. --- Java's language server (jdtls) requires special initialization that --- plain lspconfig cannot handle (workspace management, jar paths, etc.). +-- Provides nvim-jdtls: Java LSP support beyond what plain lspconfig can do. +-- jdtls needs a per-project workspace directory and, for debugging, extra jars +-- loaded into the server itself, neither of which `vim.lsp.enable` expresses. -- --- Requirements: --- Install jdtls via Mason (:MasonInstall jdtls) or manually. +-- nvim-jdtls does NOT start the server on its own; it exposes +-- `require("jdtls").start_or_attach(config)` and expects the config to call it +-- per Java buffer. That call lives in this bundle's `config` below. -- --- The plugin auto-starts jdtls when you open a .java file. -- Override settings in user/plugins/: -- { "mfussenegger/nvim-jdtls", opts = { settings = { java = { ... } } } } +--- Build the jdtls client config for the current buffer. +--- +--- The workspace directory is per project and must not be shared: jdtls stores +--- an index there, and pointing two projects at one directory corrupts it. +---@return table +local function jdtls_config() + local mason = vim.fs.joinpath(vim.fn.stdpath("data"), "mason") + local root = vim.fs.root(0, { "pom.xml", "build.gradle", "build.gradle.kts", "mvnw", "gradlew", ".git" }) + or vim.fn.getcwd() + + -- The debug and test jars are loaded by the server, not by nvim-dap, so + -- they are passed through `init_options.bundles`. `vim.fn.glob` returns an + -- empty string when nothing matches, which would otherwise put a bogus "" + -- entry in the list and make jdtls reject the whole set. + local bundles = {} + for _, pattern in ipairs({ + vim.fs.joinpath(mason, "packages", "java-debug-adapter", "extension", "server", + "com.microsoft.java.debug.plugin-*.jar"), + vim.fs.joinpath(mason, "packages", "java-test", "extension", "server", "*.jar"), + }) do + for _, jar in ipairs(vim.fn.glob(pattern, true, true)) do + if jar ~= "" then bundles[#bundles + 1] = jar end + end + end + + return { + cmd = { vim.fs.joinpath(mason, "bin", "jdtls"), "-data", + vim.fs.joinpath(vim.fn.stdpath("cache"), "jdtls", vim.fn.fnamemodify(root, ":p:h:t")) }, + root_dir = root, + init_options = { bundles = bundles }, + } +end + return { { "mfussenegger/nvim-jdtls", ft = "java", + config = function(_, opts) + local function start(bufnr) + if vim.bo[bufnr].filetype ~= "java" then return end + if vim.fn.executable("java") ~= 1 then return end + local config = vim.tbl_deep_extend("force", jdtls_config(), opts or {}) + + -- nvim-jdtls registers no DAP configurations by itself, the same + -- way it starts no client by itself. Without these two calls the + -- debug jars are loaded into the server and `dap.configurations + -- .java` is still empty, so has nothing to offer. Resolving + -- the main classes is an LSP request, so it waits for attach. + config.on_attach = function() + if not pcall(require, "dap") then return end + pcall(function() + require("jdtls").setup_dap({ hotcodereplace = "auto" }) + require("jdtls.dap").setup_dap_main_class_configs() + end) + end + + require("jdtls").start_or_attach(config) + end + + -- Both halves are needed. `ft = "java"` means lazy loads this on the + -- first Java buffer and re-fires FileType for it, but an autocmd + -- registered here would still miss that buffer on some paths, so + -- start it directly as well; the autocmd covers every later one. + vim.api.nvim_create_autocmd("FileType", { + group = vim.api.nvim_create_augroup("noethervim_jdtls", { clear = true }), + pattern = "java", + callback = function(ev) start(ev.buf) end, + }) + start(vim.api.nvim_get_current_buf()) + end, + }, + + -- jdtls is a Mason package like any other server. mason-lspconfig installs + -- it; nothing enables it through `vim.lsp.enable`, because nvim-jdtls + -- starts the client itself with the config above. + { "neovim/nvim-lspconfig", + opts = function(_, opts) + opts.ensure_installed = opts.ensure_installed or {} + vim.list_extend(opts.ensure_installed, { "jdtls" }) + end, + }, + + { "nvim-treesitter/nvim-treesitter", + opts = { ensure_installed = { "java" } }, + }, + + { "stevearc/conform.nvim", + opts = function(_, opts) + opts.formatters_by_ft = opts.formatters_by_ft or {} + opts.formatters_by_ft.java = { "google-java-format" } + opts.mason_install = opts.mason_install or {} + table.insert(opts.mason_install, "google-java-format") + end, + }, + + -- ── Java debug adapter ──────────────────────────────────────────────── + -- `optional = true` gates this on tools/debug.lua, like every other + -- language bundle. + -- + -- 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. That is why there is no `dap.adapters.java` + -- to define; asking Mason for the jars is what makes `nvim-jdtls` register + -- the adapter when it starts. `java-test` carries the JUnit half that + -- neotest-java drives. + { + "mfussenegger/nvim-dap", + optional = true, + opts = function(_, opts) + opts.mason_install = opts.mason_install or {} + vim.list_extend(opts.mason_install, { "java-debug-adapter", "java-test" }) + end, }, -- ── Java test adapter ───────────────────────────────────────────────── -- `optional = true` means lazy.nvim drops this fragment unless neotest is -- required by something else, i.e. unless tools/test.lua is enabled. -- - -- Built in an `opts` function so the `require` runs after the adapter - -- plugin loads; see tools/test.lua for why `adapters` merges as it does. - -- - -- neotest-java reads the classpath from the running jdtls client, which - -- is what nvim-jdtls above starts, and detects Maven or Gradle from the + -- neotest-java reads the classpath from the running jdtls client, which is + -- what the config above starts, and detects Maven or Gradle from the -- project itself. { "nvim-neotest/neotest", diff --git a/lua/noethervim/bundles/languages/latex.lua b/lua/noethervim/bundles/languages/latex.lua index c19ac50..f09c9c1 100644 --- a/lua/noethervim/bundles/languages/latex.lua +++ b/lua/noethervim/bundles/languages/latex.lua @@ -83,6 +83,12 @@ return { -- it. Same reason the treesitter block below says so. { "stevearc/conform.nvim", opts = function(_, opts) + -- latexindent ships with TeX Live, which this bundle already requires, + -- so the filetype is claimed without adding a Mason install. bibclean is + -- the exception: core claims `bib` and leaves the binary to whoever + -- actually opens a .bib file. + opts.formatters_by_ft = opts.formatters_by_ft or {} + opts.formatters_by_ft.tex = { "latexindent" } opts.mason_install = opts.mason_install or {} vim.list_extend(opts.mason_install, { "bibclean" }) end, diff --git a/lua/noethervim/bundles/languages/python.lua b/lua/noethervim/bundles/languages/python.lua index 93b7e89..bec4729 100644 --- a/lua/noethervim/bundles/languages/python.lua +++ b/lua/noethervim/bundles/languages/python.lua @@ -39,15 +39,39 @@ return { opts = {}, }, + { "nvim-treesitter/nvim-treesitter", + opts = { ensure_installed = { "python", "toml" } }, + }, + + -- basedpyright and ruff are in core's `ensure_installed`, so the language + -- server half is already covered; black is not, and core does not claim + -- the filetype it cannot install for. + { "stevearc/conform.nvim", + opts = function(_, opts) + opts.formatters_by_ft = opts.formatters_by_ft or {} + opts.formatters_by_ft.python = { "black" } + opts.mason_install = opts.mason_install or {} + table.insert(opts.mason_install, "black") + end, + }, + -- ── Python debug adapter ────────────────────────────────────────────── -- `optional = true` means lazy.nvim drops this whole fragment unless -- nvim-dap is required by something else, i.e. unless tools/debug.lua is -- enabled. Enabling this bundle alone installs no debugger. -- - -- dap-python.setup() with no argument launches the adapter with `python3` - -- from PATH; the interpreter the debuggee runs under is resolved per - -- session from VIRTUAL_ENV / CONDA_PREFIX, which is exactly what - -- venv-selector above sets. + -- Two interpreters are in play and they are not the same one. The argument + -- to setup() is the interpreter that RUNS THE ADAPTER, and it must be able + -- to `import debugpy`; the interpreter the DEBUGGEE runs under is resolved + -- per session from VIRTUAL_ENV / CONDA_PREFIX, which is what venv-selector + -- above sets, and is untouched by this. + -- + -- Calling setup() with no argument points the adapter at `python3` from + -- PATH, which on a normal machine cannot import debugpy: Mason installs it + -- into its own venv. The result is a registered adapter, an installed + -- package, and a debugger that never starts. Point it at the venv Mason + -- actually filled, and fall back to `python3` for someone who installed + -- debugpy themselves. { "mfussenegger/nvim-dap", optional = true, @@ -56,10 +80,20 @@ return { "mfussenegger/nvim-dap-python", ft = "python", config = function() - require("dap-python").setup() + local mason_python = vim.fs.joinpath(vim.fn.stdpath("data"), + "mason", "packages", "debugpy", "venv", "bin", "python") + require("dap-python").setup( + vim.uv.fs_stat(mason_python) and mason_python or "python3") end, }, }, + -- Mason's debugpy is a standalone copy, which is the right one for the + -- adapter process itself; the debuggee still runs under whichever + -- interpreter :VenvSelect exported. + opts = function(_, opts) + opts.mason_install = opts.mason_install or {} + table.insert(opts.mason_install, "debugpy") + end, }, -- ── Python test adapter ─────────────────────────────────────────────── diff --git a/lua/noethervim/bundles/languages/rust.lua b/lua/noethervim/bundles/languages/rust.lua index eabd6b2..c8c1e60 100644 --- a/lua/noethervim/bundles/languages/rust.lua +++ b/lua/noethervim/bundles/languages/rust.lua @@ -3,65 +3,170 @@ ---@about rustaceanvim goes past plain rust-analyzer with macro expansion, --- runnables and debuggables, the crate graph, hover actions and --- structural search-replace. It manages its own LSP client, so no ---- lspconfig entry is needed. With the test bundle also enabled, it ---- supplies its own neotest adapter. +--- lspconfig entry is needed. With the test bundle also enabled, cargo +--- tests run through neotest; with the debug bundle, it loads debug +--- targets from rust-analyzer once an adapter is installed. ---@requires exe=rust-analyzer label="rust-analyzer" --- why="every rustaceanvim feature" --- install="rustup component add rust-analyzer" ---@requires exe=cargo label="Cargo" --- why="building and running from the editor" --- install="https://rustup.rs/" ----@requires note="codelldb or lldb" +---@requires exe=cargo-nextest label="cargo-nextest" +--- why="running tests, when the test bundle is also enabled; +--- neotest-rust drives nextest rather than cargo test" +--- install="cargo install cargo-nextest" optional=true +---@requires exe=codelldb label="codelldb" --- why="stepping through Rust, when the debug bundle is also enabled" ---- install=":MasonInstall codelldb" optional=true +--- install=":MasonInstall codelldb, or put lldb-dap on PATH" optional=true -- NoetherVim bundle: Rust -- Enable with: { import = "noethervim.bundles.languages.rust" } -- --- Provides rustaceanvim -- enhanced Rust development beyond plain rust-analyzer. +-- Provides rustaceanvim: enhanced Rust development beyond plain rust-analyzer. -- Macro expansion, runnables/debuggables, crate graph, hover actions, -- structural search-replace, join lines, and more. -- --- rustaceanvim manages its own LSP client -- no lspconfig setup needed --- for rust-analyzer. Just ensure rust-analyzer is installed. +-- Commands: +-- :RustLsp runnables run a target (binary, test, doctest) +-- :RustLsp testables run tests; reports into neotest when that +-- bundle is enabled, a terminal otherwise +-- :RustLsp debuggables debug a target (needs the debug bundle) +-- :RustLsp expandMacro expand the macro under the cursor +-- :RustLsp explainError rustc --explain for the error under the cursor +-- :RustLsp openCargo open the current package's Cargo.toml +-- :RustLsp openDocs docs.rs for the symbol under the cursor +-- :RustLsp parentModule jump to the parent module +-- :RustLsp ssr structural search and replace +-- :RustLsp crateGraph render the crate graph (needs graphviz) +-- :RustAnalyzer restart restart the language server +-- `:help rustaceanvim` lists the rest. -- --- Override settings in user/plugins/: --- { "mrcjkb/rustaceanvim", opts = { server = { settings = { ... } } } } +-- :RustLsp is created when rust-analyzer finishes initializing and removed +-- when it exits, so on a cold crate it does not exist for the first seconds +-- after opening a buffer; until then it reports E492. :RustAnalyzer is +-- available as soon as a Rust buffer opens. -- --- Debugging needs no fragment here: rustaceanvim autoloads dap configurations --- itself once rust-analyzer attaches, so enabling tools/debug.lua is enough --- on the Neovim side. It still needs a debug adapter binary -- codelldb or --- lldb -- which nothing here installs. Testing does need a fragment; see below. +-- rustaceanvim manages its own LSP client, so there is no lspconfig setup for +-- rust-analyzer here. Just make sure rust-analyzer is installed. +-- +-- Override settings in user/plugins/. rustaceanvim has no setup() function; it +-- reads `vim.g.rustaceanvim` once, when its config module is first required: +-- vim.g.rustaceanvim = { server = { settings = { ... } } } +-- +-- Debugging needs no fragment here: rustaceanvim reads its debug targets from +-- rust-analyzer once the client attaches, so enabling tools/debug.lua is +-- enough on the Neovim side. It resolves the adapter binary itself, from +-- `codelldb` or `lldb-dap` on PATH; with neither installed it registers no +-- configurations and reports none. Testing does need a fragment; below. return { { "mrcjkb/rustaceanvim", version = "^6", - ft = "rust", + + init = function() + -- rustaceanvim reads this global once and has no setup() to merge + -- with, so a user config that sets it owns the whole table. Only + -- fill it in when nobody else has. + if vim.g.rustaceanvim ~= nil then return end + + vim.g.rustaceanvim = { + server = { + ---@param project_root string|nil + ---@param default_settings table|nil + settings = function(project_root, default_settings) + local settings = require("rustaceanvim.config.server") + .load_rust_analyzer_settings(project_root, { default_settings = default_settings }) + + -- A .rs file with no crate around it starts rust-analyzer + -- detached, and `cargo check` cannot work there: cargo + -- treats the lone file as a single-file package and drives + -- rustc with nightly-only flags, so on a stable toolchain + -- every save answers with a compiler backtrace. Opening a + -- scratch file should be quiet. + if not project_root then + settings["rust-analyzer"] = settings["rust-analyzer"] or {} + settings["rust-analyzer"].checkOnSave = false + end + return settings + end, + }, + } + end, + + -- Upstream's own guidance ("this plugin is already lazy"): rustaceanvim + -- does all its work from ftplugin files, so loading it is little more + -- than a runtimepath entry. `ft = "rust"` looks tighter but costs the + -- Cargo.toml half, since rustaceanvim also ships an ftplugin/toml.lua + -- that reloads the workspace when you save a manifest; under `ft` that + -- file never runs unless a Rust buffer opened first. + lazy = false, + }, + + -- Treesitter is the exception to the list-replacement rule: core declares + -- `opts_extend = { "ensure_installed" }`, so this appends. `toml` comes + -- along for Cargo.toml, which is as much a Rust file as anything here. + { "nvim-treesitter/nvim-treesitter", + opts = { ensure_installed = { "rust", "toml" } }, + }, + + -- rustfmt arrives with the toolchain rather than from Mason, so this + -- claims the filetype without adding an install. + { "stevearc/conform.nvim", + opts = function(_, opts) + opts.formatters_by_ft = opts.formatters_by_ft or {} + opts.formatters_by_ft.rust = { "rustfmt" } + end, + }, + + -- ── Rust debug adapter ──────────────────────────────────────────────── + -- `optional = true` gates this on tools/debug.lua, like every other + -- language bundle. rustaceanvim registers the configurations itself, from + -- rust-analyzer, so there is no adapter to define here; what it cannot do + -- is produce the binary those configurations launch. It looks for + -- `codelldb` or `lldb-dap` on PATH and silently registers nothing when + -- neither is there, which is the whole of the "debugging does nothing" + -- failure. Asking Mason for codelldb closes it. + { + "mfussenegger/nvim-dap", + optional = true, + opts = function(_, opts) + opts.mason_install = opts.mason_install or {} + table.insert(opts.mason_install, "codelldb") + end, }, -- ── Rust test adapter ───────────────────────────────────────────────── -- `optional = true` means lazy.nvim drops this fragment unless neotest is -- required by something else, i.e. unless tools/test.lua is enabled. -- - -- The adapter ships inside rustaceanvim rather than as its own plugin, so - -- there is no extra repo to install -- but it does have to be registered - -- by hand, and registering it is also what makes `:RustLsp testables` - -- report into neotest instead of running in a terminal. + -- neotest-rust owns the adapter rather than the one bundled with + -- rustaceanvim, because rustaceanvim's result parsing is wrong in both of + -- its modes: scraping `cargo test` stdout attributes the process exit code + -- to every discovered test, so one failure marks the whole run red, and the + -- nextest path looks for `` while nextest emits a self-closing + -- ``. Measured on a crate with one passing and one failing + -- test: rustaceanvim reports 0 passed / 2 failed, neotest-rust reports + -- 1 and 1, which is what `cargo test` says. -- - -- Built in an `opts` function so the `require` runs after rustaceanvim - -- loads; see tools/test.lua for why `adapters` merges as it does. + -- Requiring `rustaceanvim.neotest` anyway is deliberate and is not dead + -- code. `:RustLsp testables` picks its executor by asking whether that + -- module is in `package.loaded`; when it is, the command resolves a neotest + -- position id and calls `neotest.run.run(id)` rather than opening a + -- terminal. Both build the same `::::` id, so the + -- command keeps reporting into neotest while neotest-rust produces the + -- results. -- - -- rustaceanvim is listed as a dependency so the require resolves without - -- relying on a rust buffer having been opened first. The cost is that the - -- first :Neotest in any project loads it; neotest wants every adapter at - -- setup time, so there is no per-filetype way around that. + -- Built in an `opts` function so the requires run after the plugins load; + -- see tools/test.lua for why `adapters` merges as it does. { "nvim-neotest/neotest", optional = true, - dependencies = { "mrcjkb/rustaceanvim" }, + dependencies = { "mrcjkb/rustaceanvim", "rouge8/neotest-rust" }, opts = function(_, opts) + pcall(require, "rustaceanvim.neotest") opts.adapters = opts.adapters or {} - table.insert(opts.adapters, require("rustaceanvim.neotest")) + table.insert(opts.adapters, require("neotest-rust")) end, }, } diff --git a/lua/noethervim/bundles/languages/web-dev.lua b/lua/noethervim/bundles/languages/web-dev.lua index 48aac70..7e6b3a4 100644 --- a/lua/noethervim/bundles/languages/web-dev.lua +++ b/lua/noethervim/bundles/languages/web-dev.lua @@ -35,6 +35,15 @@ return { end, }, + -- The parsers arrived via core's `auto_install` before, which works but + -- states no dependency: nothing recorded that this bundle needs them, so + -- nothing would notice if auto_install were turned off. + { "nvim-treesitter/nvim-treesitter", + opts = { ensure_installed = { + "typescript", "javascript", "tsx", "css", "html", "json", + } }, + }, + { "axelvc/template-string.nvim", ft = { "html", "typescript", "javascript", "typescriptreact", "javascriptreact", "vue", "svelte", "python" }, @@ -72,51 +81,61 @@ return { -- ── JavaScript / TypeScript debug adapter ───────────────────────────── -- `optional = true` means lazy.nvim drops this whole fragment unless -- nvim-dap is required by something else, i.e. unless tools/debug.lua is - -- enabled. That gating is what keeps the cost proportionate: - -- vscode-js-debug is ~430 MB and its `build` step runs `npm i` at install - -- time, which is only worth paying for by someone who writes JavaScript. + -- enabled. + -- + -- js-debug ships two servers and the difference is not cosmetic. + -- `vsDebugServer` is the VS Code flavour: it expects the editor to answer a + -- `startDebugging` reverse request and run the debuggee in a child session. + -- Against nvim-dap that request never arrived, so the child session was + -- never created, nothing ever stopped, and a breakpoint did nothing -- + -- including with `stopOnEntry`, because the session that would stop did not + -- exist. `dapDebugServer` is the standalone-DAP entry point and speaks to a + -- plain DAP client directly. + -- + -- Mason's `js-debug-adapter` package is that release, which also retires a + -- ~430 MB source checkout whose `build` ran `npm i` at install time. { "mfussenegger/nvim-dap", optional = true, - dependencies = { - { - "microsoft/vscode-js-debug", - lazy = true, - version = "1.x", - build = "npm i && npm run compile vsDebugServerBundle && mv dist out", - }, - { - "mxsdev/nvim-dap-vscode-js", - lazy = true, - opts = { - debugger_path = vim.fn.stdpath("data") .. "/lazy/vscode-js-debug", - adapters = { "pwa-node", "pwa-chrome", "pwa-msedge", "node-terminal", "pwa-extensionHost" }, - }, - config = function(_, opts) - require("dap-vscode-js").setup(opts) + opts = function(_, opts) + opts.mason_install = opts.mason_install or {} + table.insert(opts.mason_install, "js-debug-adapter") - -- `${workspaceFolder}` is resolved by nvim-dap per session, - -- so the debuggee runs from wherever the session starts. - -- A literal `vim.fn.getcwd()` here would be evaluated once, - -- at plugin load, and freeze that directory. - local dap = require("dap") - for _, ft in ipairs({ "javascript", "typescript", "javascriptreact", "typescriptreact" }) do - dap.configurations[ft] = { - { - type = "pwa-node", name = "Launch file", request = "launch", - program = "${file}", cwd = "${workspaceFolder}", - sourceMaps = true, protocol = "inspector", console = "integratedTerminal", - }, - { - type = "pwa-node", name = "Attach to process", request = "attach", - processId = require("dap.utils").pick_process, cwd = "${workspaceFolder}", - sourceMaps = true, protocol = "inspector", console = "integratedTerminal", - }, - } - end - end, - }, - }, + local dap = require("dap") + local server = vim.fs.joinpath(vim.fn.stdpath("data"), "mason", "packages", + "js-debug-adapter", "js-debug", "src", "dapDebugServer.js") + + -- One server backs every js-debug adapter type; they differ only in + -- the `type` a configuration names. + for _, name in ipairs({ "pwa-node", "pwa-chrome", "pwa-msedge", + "node-terminal", "pwa-extensionHost" }) do + dap.adapters[name] = { + type = "server", + host = "localhost", + port = "${port}", + executable = { command = "node", args = { server, "${port}" } }, + } + end + + -- `${workspaceFolder}` is resolved by nvim-dap per session, so the + -- debuggee runs from wherever the session starts. A literal + -- `vim.fn.getcwd()` here would be evaluated once, at load, and + -- freeze that directory. + for _, ft in ipairs({ "javascript", "typescript", "javascriptreact", "typescriptreact" }) do + dap.configurations[ft] = { + { + type = "pwa-node", name = "Launch file", request = "launch", + program = "${file}", cwd = "${workspaceFolder}", + sourceMaps = true, protocol = "inspector", + }, + { + type = "pwa-node", name = "Attach to process", request = "attach", + processId = require("dap.utils").pick_process, cwd = "${workspaceFolder}", + sourceMaps = true, protocol = "inspector", + }, + } + end + end, }, -- ── JavaScript / TypeScript test adapters ───────────────────────────── diff --git a/lua/noethervim/bundles/tools/debug.lua b/lua/noethervim/bundles/tools/debug.lua index eeef7f3..e401f38 100644 --- a/lua/noethervim/bundles/tools/debug.lua +++ b/lua/noethervim/bundles/tools/debug.lua @@ -20,9 +20,10 @@ -- • languages/go.lua: delve, via nvim-dap-go -- • languages/web-dev.lua: vscode-js-debug, for JavaScript and TypeScript -- • languages/c-cpp.lua: codelldb, for C and C++ --- • languages/rust.lua: nothing to register -- rustaceanvim autoloads --- its own configurations when rust-analyzer --- attaches; it still needs codelldb or lldb +-- • languages/rust.lua: codelldb or lldb-dap; rustaceanvim loads the +-- configurations from rust-analyzer, but only once +-- one of those adapters resolves. Until then the +-- list is empty; :checkhealth noethervim says so -- -- Related bundles (enable separately): -- • test.lua: neotest test runner @@ -279,6 +280,20 @@ return { -- ── DAP client ──────────────────────────────────────────────────────────── { -- Debug Adapter Protocol client "mfussenegger/nvim-dap", + -- Debug adapter binaries are collected in `opts.mason_install`, the + -- same shape conform and nvim-lint use for formatters and linters. + -- Registering `dap.adapters.codelldb` is only half the job: a + -- configuration naming an adapter whose binary was never installed + -- fails at the moment you pick it, which is the worst place to find + -- out. Language bundles append with `opts = function(_, opts) ... end`. + -- + -- Deliberately no `opts = { mason_install = {} }` seed here. 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 everything the language bundles appended. + -- The opts functions create the list themselves; `config` treats a nil + -- as an empty one. + opts_extend = { "mason_install" }, dependencies = { { "rcarriga/nvim-dap-ui", @@ -355,11 +370,13 @@ return { { "dw", function() require("dapui").elements.watches.add(vim.fn.expand("")) end, desc = "DAP: Watch Word" }, { "dt", function() require("dap").disconnect() end, desc = "DAP: Disconnect" }, }, - config = function() + config = function(_, opts) local dap = require("dap") local dapui = require("dapui") local ic = require("noethervim.util.icons") + require("noethervim.util.mason_install").ensure(opts.mason_install) + -- Highlights for the active stopped line (Visual-bright) and for -- ancestor callstack frames. The ancestor bg is derived by blending -- Normal toward Visual so it sits between "ignore" and "active stop", diff --git a/lua/noethervim/bundles/tools/task-runner.lua b/lua/noethervim/bundles/tools/task-runner.lua index 3ca85cb..19ea321 100644 --- a/lua/noethervim/bundles/tools/task-runner.lua +++ b/lua/noethervim/bundles/tools/task-runner.lua @@ -1,8 +1,9 @@ ---@bundle task-runner ---@desc run builds and project tasks from the editor ---@about overseer.nvim runs and tracks tasks, compiler.nvim wraps it in a ---- project compiler UI, and rf runs the current file in a way ---- that respects filetype and version managers. +--- project compiler UI, and rf and rp run the current +--- file or the project around it, respecting filetype, project markers +--- and version managers. ---@requires note="your project build tool" --- why="overseer and compiler.nvim shell out to it" --- install="make, cargo, latexmk, npm, ... whatever the project uses" @@ -14,27 +15,15 @@ -- compiler.nvim: project compiler UI (:CompilerOpen, :CompilerToggleResults) -- -- Keymaps: --- rf run current file (filetype-aware, version-manager-aware) +-- rf run the current file +-- rp run the project around it (cargo, go.mod, npm, Maven, make) -- toggle task list --- Filetype → interpreter command. --- For commands with subcommands (e.g. "go run"), the first word is resolved --- through version managers while the rest is preserved. -local runners = { - python = "python3", - lua = "lua", - javascript = "node", - typescript = "tsx", - go = "go run", - sh = "sh", - bash = "bash", - zsh = "zsh", - ruby = "ruby", - julia = "julia", - perl = "perl", - r = "Rscript", - php = "php", -} +-- What each language runs comes from `noethervim.util.run`, shared with core's +-- code_runner so a language is taught once. What is local to this bundle is +-- running it through overseer, and resolving the interpreter through whichever +-- version manager governs the directory. +local run = require("noethervim.util.run") -- General-purpose version managers, tried first in order. -- All support ` which ` and respect per-directory config. @@ -91,41 +80,55 @@ local function resolve_runner(cmd, dir) return cmd end +--- Run the current file, or the project around it, as an overseer task. +--- +--- The version-manager pass applies only to the interpreter forms, where the +--- command begins with a bare binary name we might resolve to a per-directory +--- install. A build tool invoked through its own project (`cargo run`, +--- `./gradlew run`, `make`) already resolves itself, and rewriting its first +--- word would be wrong. +---@param kind "file"|"project" +local function start_task(kind) + local cmd, cwd = run.command(kind, 0) + if not cmd or not cwd then + local ft = vim.bo.filetype + if kind == "project" then + vim.notify(("No project to run for %s here (looked for %s)"):format( + ft ~= "" and ft or "this buffer", + table.concat((run.languages[ft] or {}).root or { "a project marker" }, ", ")), + vim.log.levels.WARN) + else + vim.notify("No runner for filetype: " .. (ft ~= "" and ft or "(none)"), vim.log.levels.WARN) + end + return + end + + local spec = run.languages[vim.bo.filetype] or {} + if type(spec[kind]) == "string" then + local bin = cmd:match("^(%S+)") + cmd = resolve_runner(bin, cwd) .. cmd:sub(#bin + 1) + end + + require("overseer").new_task({ + name = (kind == "project" and "Run project: " or "Run ") .. vim.fn.fnamemodify(cwd, ":t"), + cmd = cmd, + cwd = cwd, + components = { + "default", + { "on_complete_notify", statuses = { "SUCCESS", "FAILURE" } }, + "open_output", + }, + }):start() +end + return { { "stevearc/overseer.nvim", cmd = { "OverseerRun", "OverseerToggle" }, keys = { - { - "rf", - function() - local ft = vim.bo.filetype - local cmd = runners[ft] - if not cmd then - vim.notify("No runner for filetype: " .. ft, vim.log.levels.WARN) - return - end - - local file = vim.fn.shellescape(vim.fn.expand("%:p")) - local dir = vim.fn.expand("%:p:h") - local name = vim.fn.expand("%:t") - - cmd = resolve_runner(cmd, dir) - - require("overseer").new_task({ - name = "Run " .. name, - cmd = cmd .. " " .. file, - cwd = dir, - components = { - "default", - { "on_complete_notify", statuses = { "SUCCESS", "FAILURE" } }, - "open_output", - }, - }):start() - end, - desc = "Run this [f]ile", - }, - { "", "OverseerToggle", desc = "Task list" }, + { "rf", function() start_task("file") end, desc = "Run this [f]ile" }, + { "rp", function() start_task("project") end, desc = "Run this [p]roject" }, + { "", "OverseerToggle", desc = "Task list" }, }, opts = { task_list = { diff --git a/lua/noethervim/health.lua b/lua/noethervim/health.lua index a9fa0b5..af31813 100644 --- a/lua/noethervim/health.lua +++ b/lua/noethervim/health.lua @@ -697,6 +697,13 @@ function M.check() if vim.env.NOETHERVIM_NO_USER or vim.g.noethervim_no_user then h.info("User overrides: SUPPRESSED (NOETHERVIM_NO_USER or vim.g.noethervim_no_user set)") end + if vim.g.noethervim_auto_install == false then + h.info("Toolchain auto-install: OFF (vim.g.noethervim_auto_install = false)\n" + .. "Formatters, linters and debug adapters named by enabled bundles are\n" + .. "not fetched; install them yourself or with :Mason.") + else + h.ok("Toolchain auto-install: ON -- enabled bundles fetch their Mason tools") + end end return M diff --git a/lua/noethervim/lsp/gopls.lua b/lua/noethervim/lsp/gopls.lua new file mode 100644 index 0000000..8098bbc --- /dev/null +++ b/lua/noethervim/lsp/gopls.lua @@ -0,0 +1 @@ +vim.lsp.enable('gopls') diff --git a/lua/noethervim/plugins/code-runner.lua b/lua/noethervim/plugins/code-runner.lua index e9ae55f..5a56e6f 100644 --- a/lua/noethervim/plugins/code-runner.lua +++ b/lua/noethervim/plugins/code-runner.lua @@ -1,27 +1,69 @@ --- for code running -return{ 'CRAG666/code_runner.nvim', -keys = { - {'RR', 'RunCode', { noremap = true, silent = false, desc = 'run code' }}, - -- {'', 'RunCode', { noremap = true, silent = false, desc = 'run code' }}, -}, -config = function() - require("code_runner").setup({ - mode = 'float', - float = { border = "double" }, - filetype = { - java = { "cd $dir &&", "javac $fileName &&", "java $fileNameWithoutExt" }, - python = "python3 -u", - typescript = "deno run", - rust = { "cd $dir &&", "rustc $fileName &&", "$dir/$fileNameWithoutExt" }, - }, - }) - vim.keymap.set("n", "RT", function() - local ok, bt = pcall(require, "betterTerm") - if not ok then - vim.notify("betterTerm not available (enable noethervim.suites.better-term)", vim.log.levels.WARN) - return +-- NoetherVim plugin: Code Runner +-- ╔══════════════════════════════════════════════════════════╗ +-- ║ code runner ║ +-- ╚══════════════════════════════════════════════════════════╝ +-- Run the current file in a float, or send it to a betterTerm terminal. +-- +-- What each language runs is not decided here: it comes from +-- `noethervim.util.run`, which the task-runner bundle reads too, so `cargo` +-- and `go.mod` are understood once rather than once per runner. +-- +-- Both keys live under `r` (Run/REPL), alongside the REPL bundle's +-- `rs`/`rr`/`rF`/`rh` and the task-runner bundle's `rf`/`rp`. Both are +-- declared in `keys` rather than one of them in `config`: `keys` is the only +-- load trigger, so a map created inside `config` would not exist until the +-- other one fired. + +local run = require("noethervim.util.run") + +--- Hand code_runner a fully-built command for `ft`. +--- +--- code_runner appends the buffer path to any command it did not substitute a +--- `$var` into, which would duplicate the filename our command already +--- carries. `$end` expands to the empty string and counts as a substitution, +--- so it suppresses that append without adding anything. +---@param ft string +---@return fun():string|nil +local function command_for(ft) + return function() + local cmd, cwd = run.command("file", 0) + if not cmd then + vim.notify("No runner for filetype: " .. ft, vim.log.levels.WARN) + return nil end - bt.send(require("code_runner.commands").get_filetype_command(), 1, { clean = false, interrupt = true }) - end, { desc = "Run in terminal (betterTerm)" }) + return ("cd %s && %s $end"):format(vim.fn.shellescape(cwd), cmd) + end +end + +-- code_runner keys off the filetype table it is given, so every language +-- util/run knows about has to appear here for `:RunCode` to reach it. +local filetype = {} +for ft in pairs(run.languages) do + filetype[ft] = command_for(ft) end + +return { 'CRAG666/code_runner.nvim', + keys = { + { 'rc', 'RunCode', desc = 'run [c]ode' }, + { + 'rT', + function() + local ok, bt = pcall(require, "betterTerm") + if not ok then + vim.notify("betterTerm not available (enable noethervim.bundles.terminal.better-term)", + vim.log.levels.WARN) + return + end + local cmd = require("code_runner.commands").get_filetype_command() + if not cmd or cmd == "" then return end + bt.send(cmd, 1, { clean = false, interrupt = true }) + end, + desc = 'run in [T]erminal (betterTerm)', + }, + }, + opts = { + mode = 'float', + float = { border = "double" }, + filetype = filetype, + }, } diff --git a/lua/noethervim/plugins/lsp.lua b/lua/noethervim/plugins/lsp.lua index 029f07c..ba494e6 100644 --- a/lua/noethervim/plugins/lsp.lua +++ b/lua/noethervim/plugins/lsp.lua @@ -59,7 +59,6 @@ return { opts = { formatters_by_ft = { lua = { "stylua" }, - python = { "black" }, bib = { "bibclean" }, javascript = { "prettierd" }, javascriptreact = { "prettierd" }, @@ -74,33 +73,27 @@ return { }, -- No format_on_save -- use ff for explicit formatting. - -- Mason packages to fetch eagerly, rather than on first format. - -- Only the ones every install needs: core is written in Lua, so - -- stylua earns it. `bib` is claimed above but its formatter is - -- not here -- a .bib file is not something a user without LaTeX - -- opens, so `languages/latex` adds `bibclean` to this list. + -- Mason packages backing the claims above. The rule is that + -- whoever claims a filetype installs its formatter: core claims + -- the ones every install meets regardless of language (Lua for the + -- config itself, shell scripts, and the JSON / YAML / Markdown + -- that prettierd covers), and a language bundle claims its own. + -- `bib` is the one claim core makes without installing, because a + -- .bib file is not something a user without LaTeX opens; the latex + -- bundle adds `bibclean` to this list. -- -- A list, not a second `config`: lazy keeps only the last config -- function it sees, so a bundle defining one would silently drop -- this whole block. Bundles append with -- `opts = function(_, opts) ... end`, the same way they extend -- `ensure_installed`. - mason_install = { "stylua" }, + mason_install = { "stylua", "prettierd", "shfmt" }, }, config = function(_, opts) local tools = opts.mason_install or {} opts.mason_install = nil -- conform would reject the unknown key require("conform").setup(opts) - local ok_mr, mr = pcall(require, "mason-registry") - if not ok_mr then return end - mr.refresh(function() - for _, tool in ipairs(tools) do - local ok, pkg = pcall(mr.get_package, tool) - if ok and not pkg:is_installed() then - pkg:install() - end - end - end) + require("noethervim.util.mason_install").ensure(tools) end, }, @@ -120,10 +113,16 @@ return { -- Most linting is provided by LSP servers (basedpyright, ruff, -- eslint, lua_ls, …). Add non-LSP linters here or via opts override. }, + -- Same contract as conform's list above: a bundle that adds a + -- linter to `linters_by_ft` names its Mason package here, and the + -- binary arrives with the bundle instead of being a manual step + -- the reader only learns about from :checkhealth. + mason_install = {}, }, config = function(_, opts) local lint = require("lint") lint.linters_by_ft = opts.linters_by_ft + require("noethervim.util.mason_install").ensure(opts.mason_install) vim.api.nvim_create_autocmd({ "BufReadPost", "BufWritePost", "InsertLeave" }, { group = vim.api.nvim_create_augroup("noethervim_lint", { clear = true }), diff --git a/lua/noethervim/util/mason_install.lua b/lua/noethervim/util/mason_install.lua new file mode 100644 index 0000000..37b6019 --- /dev/null +++ b/lua/noethervim/util/mason_install.lua @@ -0,0 +1,57 @@ +--- Install the Mason packages a bundle declared it needs. +--- +--- `nvim-lspconfig`'s `ensure_installed` already does this for language +--- servers, by way of mason-lspconfig. Nothing did it for the other three +--- toolchain layers, so a bundle could name `black` in `formatters_by_ft` or +--- register a `codelldb` debug adapter and the reader would still be one +--- manual `:MasonInstall` away from the feature existing, with only +--- `:checkhealth` to say so. conform grew a private `mason_install` list for +--- exactly this problem; this is that list extracted, so conform, nvim-lint and +--- nvim-dap share one implementation and one opt-out. +--- +--- Enabling a bundle is the opt-in. That is the same bargain `ensure_installed` +--- already strikes for language servers: ask for the Go bundle and you have +--- asked for the Go toolchain the editor drives. +--- +--- Set `vim.g.noethervim_auto_install = false` to decline. For a toolchain +--- managed outside the editor (nix, system packages, a project-local venv), a +--- second copy under Mason is noise at best and a version skew at worst. + +local M = {} + +--- Queue Mason installs for any of `tools` that are missing. +--- +--- Asynchronous and best-effort by design: this runs from a plugin `config`, +--- and neither a missing Mason nor an unknown package name is worth an error +--- on the path that opens a file. `:checkhealth noethervim` is where a tool +--- that never arrived gets reported. +---@param tools string[]|nil Mason package names +function M.ensure(tools) + if vim.g.noethervim_auto_install == false then return end + if not tools or #tools == 0 then return end + + local ok, registry = pcall(require, "mason-registry") + if not ok then return end + + -- Two language bundles can name the same package: codelldb backs both + -- Rust and C/C++. Installing it twice in one pass races Mason against + -- itself over the same directory. + local seen, wanted = {}, {} + for _, tool in ipairs(tools) do + if not seen[tool] then + seen[tool] = true + wanted[#wanted + 1] = tool + end + end + + registry.refresh(function() + for _, tool in ipairs(wanted) do + local found, pkg = pcall(registry.get_package, tool) + if found and not pkg:is_installed() then + pkg:install() + end + end + end) +end + +return M diff --git a/lua/noethervim/util/run.lua b/lua/noethervim/util/run.lua new file mode 100644 index 0000000..87bfe02 --- /dev/null +++ b/lua/noethervim/util/run.lua @@ -0,0 +1,159 @@ +--- How to run the current file, and how to run the project around it. +--- +--- This used to live in two places that disagreed: `plugins/code-runner.lua` +--- knew java, python, typescript and rust, while `bundles/tools/task-runner.lua` +--- knew a different thirteen and not rust. Adding a language meant remembering +--- both, and nobody did. One table, two consumers. +--- +--- Each entry has: +--- `root` project markers, searched upward from the buffer +--- `file` how to run this one file +--- `project` how to run the whole project, when a root was found +--- +--- A string is an interpreter: the file path is appended to it. A function +--- receives the context below and returns a complete shell command, or nil +--- when the language cannot do that here. + +local M = {} + +---@class noethervim.RunContext +---@field file string shell-escaped absolute path of the buffer +---@field dir string shell-escaped directory holding it +---@field stem string shell-escaped path with the extension removed +---@field name string bare filename, for task titles +---@field root string|nil project root when a marker was found (unescaped) + +---@class noethervim.RunSpec +---@field root? string[] project markers, searched upward from the buffer +---@field file? string|fun(c: noethervim.RunContext):string|nil +---@field project? string|fun(c: noethervim.RunContext):string|nil + +---@type table +M.languages = { + -- Interpreted languages: the command is the interpreter, the file is its + -- argument, and there is no project-level answer that is true in general. + python = { file = "python3 -u" }, + lua = { file = "lua" }, + ruby = { file = "ruby" }, + julia = { file = "julia" }, + perl = { file = "perl" }, + php = { file = "php" }, + r = { file = "Rscript" }, + sh = { file = "sh" }, + bash = { file = "bash" }, + zsh = { file = "zsh" }, + + javascript = { root = { "package.json" }, file = "node", project = "npm start --silent" }, + -- `node` rather than `tsx`: Node strips types natively from 22.6 and does + -- it without a flag from 23, so a TypeScript file runs with the toolchain + -- already required for JavaScript. Naming `tsx` meant claiming a binary + -- nothing here installs, and the run failing with "command not found". + typescript = { root = { "package.json" }, file = "node", project = "npm start --silent" }, + + -- `go run .` builds the package in the working directory, which is what a + -- Go "project run" means; `go run ` is the single-file form. + go = { root = { "go.mod" }, file = "go run", project = "go run ." }, + + -- Cargo searches upward for the manifest the same way this does, so the + -- project command needs no path. Outside a crate, rustc still compiles a + -- lone file. + rust = { + root = { "Cargo.toml" }, + project = "cargo run", + file = function(c) + if c.root then return "cargo run" end + return ("rustc %s -o %s && %s"):format(c.file, c.stem, c.stem) + end, + }, + + -- `java ` is the JDK 11+ single-file source launcher: it compiles in + -- memory and honours the file's own `package` declaration. Compiling with + -- javac and running `java -cp ` looks equivalent and is not; it + -- fails for any class in a package ("wrong name: capfixture/Main"), which + -- is almost all real Java. + java = { + root = { "pom.xml", "build.gradle", "build.gradle.kts" }, + file = function(c) return "java " .. c.file end, + project = function(c) + if not c.root then return nil end + if vim.uv.fs_stat(c.root .. "/pom.xml") then return "mvn -q compile exec:java" end + return "./gradlew run" + end, + }, + + c = { root = { "Makefile", "CMakeLists.txt" }, project = "make", + file = function(c) return ("cc %s -o %s && %s"):format(c.file, c.stem, c.stem) end }, + cpp = { root = { "Makefile", "CMakeLists.txt" }, project = "make", + file = function(c) return ("c++ %s -o %s && %s"):format(c.file, c.stem, c.stem) end }, +} + +--- Describe the current buffer for the builders above. +---@param bufnr? integer +---@return noethervim.RunContext +function M.context(bufnr) + bufnr = bufnr or 0 + local path = vim.api.nvim_buf_get_name(bufnr) + local dir = vim.fn.fnamemodify(path, ":p:h") + local spec = M.languages[vim.bo[bufnr].filetype] or {} + + local root + if spec.root then + local found = vim.fs.find(spec.root, { upward = true, path = dir })[1] + if found then root = vim.fs.dirname(found) end + end + + return { + file = vim.fn.shellescape(path), + dir = vim.fn.shellescape(dir), + stem = vim.fn.shellescape(vim.fn.fnamemodify(path, ":p:r")), + name = vim.fn.fnamemodify(path, ":t"), + root = root, + } +end + +--- Build the command for one kind of run. +--- +--- Returns the command and the directory to run it from, or nil when this +--- filetype has no answer for that kind. The caller decides how to report +--- that; there is no single right message for "cannot run a .txt". +---@param kind "file"|"project" +---@param bufnr? integer +---@return string|nil cmd, string|nil cwd +function M.command(kind, bufnr) + bufnr = bufnr or 0 + local spec = M.languages[vim.bo[bufnr].filetype] + if not spec then return nil end + + local entry = spec[kind] + if not entry then return nil end + + local ctx = M.context(bufnr) + + -- A project run belongs in the project, a file run beside the file. + -- Spelled out rather than `and/or`: that idiom falls through to the + -- right-hand branch whenever the middle value is nil, which here would + -- silently run a project command in a directory that has no project. + local cwd + if kind == "project" then + if not ctx.root then return nil end + cwd = ctx.root + else + cwd = vim.fn.fnamemodify(vim.api.nvim_buf_get_name(bufnr), ":p:h") + end + + if type(entry) == "function" then + local cmd = entry(ctx) + if not cmd then return nil end + return cmd, cwd + end + + -- String form. For a file run it names an interpreter and wants the path; + -- for a project run it is already the whole command, and appending the + -- buffer would turn `cargo run` into `cargo run some/file.rs`. + if kind == "file" then + return entry .. " " .. ctx.file, cwd + end + return entry, cwd +end + +return M diff --git a/tests/capability.sh b/tests/capability.sh new file mode 100755 index 0000000..9e9f893 --- /dev/null +++ b/tests/capability.sh @@ -0,0 +1,253 @@ +#!/bin/bash +# tests/capability.sh -- Level-C language capability matrix (Phase 1). +# +# Plan: dev-docs/language-matrix-test-plan.md +# Contract being tested: dev-docs/language-bundle-contract.md +# +# Spins an ISOLATED Neovim (own NVIM_APPNAME + XDG dirs under $TMPDIR), loads +# NoetherVim core from THIS checkout plus one language bundle AND the tool +# bundles its checkpoints depend on (test / debug / task-runner), then runs +# tests/capability_assert.lua against a fixture project. +# +# Deliberately a separate root from bundle_load.sh: that harness loads exactly +# one bundle in order to attribute a failure, while these checkpoints only +# exist when a language bundle and a tool bundle are enabled together. +# +# The install root is SHARED across runs and never wiped, so plugins and Mason +# packages install once. Force a clean rebuild with NVCAP_FRESH=1. +# +# Usage: +# tests/capability.sh rust +# tests/capability.sh --all +# NVCAP_FRESH=1 tests/capability.sh go +# +# Exit code: non-zero if any checkpoint FAILs. UNCOVERED (toolchain absent on +# this machine) and N/A (declared in the plan) never gate. + +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +FIXTURES="$SCRIPT_DIR/fixtures/lang" + +LANGS=(rust go python c cpp java web latex) + +APPNAME="nvim-captest" +HARNESS_ROOT="${TMPDIR:-/tmp}/noethervim-capability-harness" + +if [ "${NVCAP_FRESH:-0}" = "1" ]; then + echo "NVCAP_FRESH=1 -- wiping $HARNESS_ROOT" + rm -rf "$HARNESS_ROOT" +fi + +export XDG_CONFIG_HOME="$HARNESS_ROOT/config" +export XDG_DATA_HOME="$HARNESS_ROOT/data" +export XDG_STATE_HOME="$HARNESS_ROOT/state" +export XDG_CACHE_HOME="$HARNESS_ROOT/cache" +export NVIM_APPNAME="$APPNAME" +CONFIG_DIR="$XDG_CONFIG_HOME/$APPNAME" +mkdir -p "$CONFIG_DIR" + +# Safety: never let a bad expansion point this at a real config. +case "$CONFIG_DIR" in + "$HOME/.config/nvim"|"$HOME/.config/noethervim") + echo "refusing: resolved config dir is a real one ($CONFIG_DIR)"; exit 2;; +esac + +NVTIMEOUT="" +if command -v timeout >/dev/null 2>&1; then NVTIMEOUT="timeout ${NVCAP_TIMEOUT:-420}" +elif command -v gtimeout >/dev/null 2>&1; then NVTIMEOUT="gtimeout ${NVCAP_TIMEOUT:-420}"; fi + +# Build artifacts from a previous run would let a stale binary answer for a +# broken command, so every fixture starts clean. +clean_fixtures() { + rm -rf "$FIXTURES/rust/target" "$FIXTURES/rust/Cargo.lock" \ + "$FIXTURES/go/capfixture" "$FIXTURES/c/main" "$FIXTURES/c/main_debug" "$FIXTURES/c/build" \ + "$FIXTURES/java/target" \ + "$FIXTURES"/*/__pycache__ "$FIXTURES"/*/.pytest_cache 2>/dev/null +} + +write_init() { + # $1 = bundle key, e.g. "languages.rust" + cat > "$CONFIG_DIR/init.lua" <].main in capability_assert.lua: provisioning +# has to open the same file the assertions do, or it warms the wrong server. +main_for() { + case "$1" in + rust) echo "src/main.rs" ;; + go) echo "main.go" ;; + python) echo "main.py" ;; + c) echo "main.c" ;; + java) echo "src/main/java/capfixture/Main.java" ;; + web) echo "main.ts" ;; + latex) echo "main.tex" ;; + cpp) echo "main.cpp" ;; + esac +} + +run_one() { + local lang="$1" + local bundle; bundle="$(bundle_for "$lang")" + + if [ ! -d "$FIXTURES/$lang" ]; then + echo "=== $lang: NO FIXTURE ($FIXTURES/$lang)"; return 3 + fi + + echo "===================================================================" + echo "LANGUAGE: $lang bundle: $bundle" + echo "isolated env: NVIM_APPNAME=$APPNAME root=$HARNESS_ROOT" + clean_fixtures + write_init "$bundle" + + echo "--- phase 1: lazy install + Mason (shared root; first run slow) ---" + local t0 t1 + t0=$(date +%s) + $NVTIMEOUT nvim --headless "+Lazy! install" "+qa" >"$HARNESS_ROOT/install.log" 2>&1 + + # `Lazy! install` clones plugins but reads no buffer, and nvim-lspconfig is + # lazy-loaded on BufReadPre. Without opening a real file of the language, + # its config never runs, mason-lspconfig's `ensure_installed` never fires, + # and the language server is simply absent -- which then reads as "the + # bundle does not attach an LSP" rather than "the harness never installed + # one". Open the fixture, then wait for Mason to drain. + # + # This is the coverage-honesty rule from full-toolchain-sweep-plan.md: a + # failure must never be a hidden missing tool. + # Run from inside the fixture. neotest roots an adapter at Neovim's cwd, + # so launching from the repo made it look for the crate's tests in the + # repo itself (adapter id "rustaceanvim:/.../NoetherVim", zero positions). + # Opening a project from within it is also what a user actually does. + ( cd "$FIXTURES/$lang" && $NVTIMEOUT nvim --headless "$(main_for "$lang")" \ + +"luafile $SCRIPT_DIR/capability_provision.lua" ) >>"$HARNESS_ROOT/install.log" 2>&1 + # neotest-java fetches a junit-platform-console-standalone jar the first + # time it runs anything. That download does not complete under a headless + # run, and the symptom is an adapter that discovers the tests and reports + # no results at all -- indistinguishable from a broken adapter. Fetch it + # up front, so an UNCOVERED here can never be a hidden missing tool. + # vitest's first run in a project is a cold start: it resolves and + # transforms the whole dependency graph before reporting anything. Under + # --all, with six other languages already having run on the machine, that + # cold start has outrun the harness's poll budget and produced a green cell + # in isolation and a red one in the suite. Warm it here so the graded run + # measures the adapter rather than the transform. + if [ "$lang" = "web" ]; then + # node_modules is not committed, so a clean checkout has no vitest at + # all. Without this the test cell fails for a missing tool rather than + # reporting UNCOVERED, which is the one thing the coverage rule forbids. + if [ ! -d "$FIXTURES/web/node_modules" ]; then + ( cd "$FIXTURES/web" && timeout 600 npm install --silent ) \ + || echo " warning: npm install failed for the web fixture" + fi + ( cd "$FIXTURES/web" && timeout 300 npx vitest run >/dev/null 2>&1 ) || true + fi + + # compile_commands.json carries an absolute directory, so it is generated + # here rather than committed: a path from the machine that wrote it is + # wrong everywhere else, and clangd would resolve includes against it. + for d in c cpp; do + if [ -d "$FIXTURES/$d" ]; then + src="main.c"; cc="cc" + [ "$d" = "cpp" ] && { src="main.cpp"; cc="c++"; } + printf '[{"directory":"%s","command":"%s -c %s","file":"%s"}]\n' \ + "$FIXTURES/$d" "$cc" "$src" "$src" > "$FIXTURES/$d/compile_commands.json" + fi + done + + # doctest.h backs the C/C++ test fixture and is third-party; fetched + # rather than vendored, so the repo does not carry 500 KB of someone + # else's header for one fixture. + if [ "$lang" = "c" ] && [ ! -f "$FIXTURES/c/doctest.h" ]; then + curl -fsSL -o "$FIXTURES/c/doctest.h" \ + "https://raw.githubusercontent.com/doctest/doctest/master/doctest/doctest.h" \ + || echo " warning: doctest.h download failed" + fi + + if [ "$lang" = "java" ]; then + local jdir="$XDG_DATA_HOME/$APPNAME/neotest-java" + local jver + jver="$(sed -n 's/.*version = "\([0-9.]*\)".*/\1/p' \ + "$XDG_DATA_HOME/$APPNAME/lazy/neotest-java/lua/neotest-java/default_config.lua" 2>/dev/null | head -1)" + if [ -n "$jver" ] && [ ! -f "$jdir/junit-platform-console-standalone-$jver.jar" ]; then + mkdir -p "$jdir" + curl -fsSL -o "$jdir/junit-platform-console-standalone-$jver.jar" \ + "https://repo1.maven.org/maven2/org/junit/platform/junit-platform-console-standalone/$jver/junit-platform-console-standalone-$jver.jar" \ + || echo " warning: junit standalone jar download failed" + fi + fi + + t1=$(date +%s) + echo "provisioning finished in $((t1 - t0))s" + echo " mason packages: $(ls "$XDG_DATA_HOME/$APPNAME/mason/packages" 2>/dev/null | tr '\n' ' ')" + + # NVCAP_PROBE swaps the assertion script for an ad-hoc one, in the env this + # language was just provisioned for. Without it, a hand-run probe executes + # against whatever init.lua the previous language left behind -- which + # silently tests the wrong bundle. + local script="${NVCAP_PROBE:-$SCRIPT_DIR/capability_assert.lua}" + + echo "--- phase 2: capability checkpoints ($(basename "$script")) ---" + ( cd "$FIXTURES/$lang" && CAP_LANG="$lang" CAP_FIXTURES="$FIXTURES" $NVTIMEOUT nvim --headless \ + +"luafile $script" +"qa!" ) + local rc=$? + echo "--- $lang: exit $rc ---" + return $rc +} + +FAILED="" +if [ "${1:-}" = "--all" ]; then + for l in "${LANGS[@]}"; do + run_one "$l" || FAILED="$FAILED $l" + done + echo + echo "===================================================================" + if [ -n "$FAILED" ]; then + echo "LANGUAGES WITH FAILING CHECKPOINTS:$FAILED" + exit 1 + fi + echo "all languages: no failing checkpoints" + exit 0 +fi + +if [ -z "${1:-}" ]; then + echo "usage: tests/capability.sh <${LANGS[*]}|--all>"; exit 2 +fi +run_one "$1" diff --git a/tests/capability_assert.lua b/tests/capability_assert.lua new file mode 100644 index 0000000..2149451 --- /dev/null +++ b/tests/capability_assert.lua @@ -0,0 +1,643 @@ +-- tests/capability_assert.lua -- Level-C language capability matrix. +-- +-- Phase 1 of dev-docs/language-matrix-test-plan.md: checkpoints 1 (LSP), +-- 2 (treesitter), 7 (run file) and 8 (run project) from +-- dev-docs/language-bundle-contract.md. +-- +-- Run by tests/capability.sh inside an isolated NVIM_APPNAME with the language +-- bundle plus tools/{test,debug,task-runner} enabled. CAP_LANG names the row. +-- +-- Every cell resolves to PASS, FAIL, N/A or UNCOVERED; none may be skipped. +-- UNCOVERED means the toolchain is genuinely absent on this machine, and never +-- gates. Exit code is driven by FAIL alone. +-- +-- Two rules the plan inherits from the session that motivated it: +-- * Poll for the real signal, never a fixed sleep. rust-analyzer answers a +-- runnables request with `cargo check` alone before the crate graph is +-- built, so a correct result looks broken if you ask too early. +-- * Suspect the harness before the code when something looks wrong. + +local LANG = vim.env.CAP_LANG or "" +local ROOT = vim.env.CAP_FIXTURES or "" + +local pass, fail, na, uncovered = 0, 0, 0, 0 +local cells = {} + +local gap = 0 + +--- Record one cell. +--- +--- GAP is a checkpoint the contract says this bundle does not meet, with an +--- issue already filed. It is reported as loudly as a FAIL but does not gate +--- the exit code, so the run distinguishes "this regressed" from "we already +--- know and it is tracked". A GAP without an issue reference is a FAIL. +local function record(cp, state, detail) + cells[#cells + 1] = { cp = cp, state = state, detail = detail } + if state == "PASS" then pass = pass + 1 + elseif state == "FAIL" then fail = fail + 1 + elseif state == "N/A" then na = na + 1 + elseif state == "GAP" then gap = gap + 1 + else uncovered = uncovered + 1 end + print(("%-9s %-11s %s"):format(state, cp, detail or "")) +end + +--- Poll until `fn` returns truthy, or the budget runs out. +---@return boolean ok, integer waited_ms +local function poll(ms, fn) + local step, waited = 200, 0 + while waited < ms do + if fn() then return true, waited end + vim.wait(step, function() return false end, 50) + waited = waited + step + end + return fn() and true or false, waited +end + +-- ── The matrix rows ─────────────────────────────────────────────────────── +-- `tool` gates the whole row: absent means UNCOVERED, never FAIL, because a +-- missing compiler says nothing about the bundle. +local SPECS = { + rust = { + bundle = "languages.rust", dir = "rust", main = "src/main.rs", ft = "rust", + tool = "cargo", lsp = { "rust-analyzer" }, parser = "rust", node = "function_item", + run_file = "42", run_project = "42", + fmt = { file = "src/messy.rs", bin = "rustfmt", expect = "a: i32" }, + -- neotest-rust drives `cargo nextest`, not `cargo test`; without it the + -- adapter discovers the tests and reports nothing. Gating on the binary + -- it actually needs makes its absence read as UNCOVERED, not FAIL. + test = { file = "src/main.rs", bin = "cargo-nextest" }, + -- Stop inside add(), where `a` is 40 regardless of how the binary was + -- launched. rustaceanvim's autoloaded "Cargo: build" config builds the + -- crate and runs the resulting binary under codelldb. + debug = { line = 3, adapter = "codelldb", config = "^Cargo: build", var = "a", value = "40" }, + -- A type mismatch, not an unresolved name: rust-analyzer reports + -- mismatches natively on didChange, while "cannot find function" comes + -- from cargo check, which only runs on save. + lint = { inject = 'fn __cap_broken() { let _x: i32 = "not an int"; }' }, + }, + go = { + bundle = "languages.go", dir = "go", main = "main.go", ft = "go", + tool = "go", lsp = { "gopls" }, parser = "go", node = "function_declaration", + run_file = "42", run_project = "42", + fmt = { file = "messy.go", bin = "goimports", expect = "func Messy(a int) int" }, + test = { file = "main_test.go", bin = "go" }, + debug = { line = 5, adapter = "go", config = "", var = "a", value = "40" }, + lint = { inject = "func __capBroken() int { return __capMissing() }" }, + }, + python = { + bundle = "languages.python", dir = "python", main = "main.py", ft = "python", + tool = "python3", lsp = { "basedpyright", "ruff" }, parser = "python", + node = "function_definition", + run_file = "42", + -- Python has no project-level run: there is no convention that is true + -- across setuptools, poetry, uv and a bare script directory. + run_project = false, + fmt = { file = "messy.py", bin = "black", expect = "def messy(a):" }, + test = { file = "test_main.py", bin = "pytest" }, + debug = { line = 2, adapter = "debugpy", config = "", var = "a", value = "40" }, + lint = { inject = "def __cap_broken():\n return __cap_missing()" }, + }, + cpp = { + bundle = "languages.c-cpp", dir = "cpp", main = "main.cpp", ft = "cpp", + tool = "c++", lsp = { "clangd" }, parser = "cpp", node = "function_definition", + run_file = "42", run_project = false, -- `make` builds; it does not run + fmt = { file = "messy.cpp", bin = "clang-format", expect = "int messy(int a)" }, + -- The bundle registers one CTest adapter for both filetypes, and the c + -- row exercises it against a real CMake project. Standing up a second + -- identical project here would duplicate, not add. + test = { na = "one CTest adapter serves both filetypes; graded on the c row" }, + debug = { line = 4, adapter = "codelldb", config = "", var = "a", value = "40", + program = "main_debug", + build = { "c++", "-g", "-O0", "main.cpp", "-o", "main_debug" } }, + lint = { inject = "int __cap_broken() { return __cap_missing(); }" }, + }, + latex = { + bundle = "languages.latex", dir = "latex", main = "main.tex", ft = "tex", + tool = "latexmk", lsp = { "texlab" }, parser = "latex", node = "section", + -- A document is compiled, not run. That capability is real and is + -- covered end to end by tests/behave_latex.lua, which asserts a PDF + -- appears; repeating it here would duplicate, not add. + run_file = false, run_project = false, + fmt = { file = "messy.tex", bin = "latexindent", expect = "\\item one", smoke = true }, + test = { na = "a LaTeX document has no test suite to run" }, + debug = { na = "a LaTeX document is not stepped through" }, + -- texlab reports LaTeX diagnostics from the build, not from parsing, so + -- a document that compiles cleanly has none and a broken one needs a + -- latexmk run to produce any. That compile path is already asserted end + -- to end by tests/behave_latex.lua. chktex would cover the parse-time + -- half, but declaring it produced no diagnostics through nvim-lint even + -- with the binary present and try_lint clean, so it is not claimed. + lint = { na = "texlab reports diagnostics from the build; see behave_latex" }, + }, + web = { + bundle = "languages.web-dev", dir = "web", main = "main.ts", ft = "typescript", + tool = "node", lsp = { "ts_ls" }, parser = "typescript", + node = "function_declaration", + run_file = "42", run_project = "42", + fmt = { file = "messy.ts", bin = "prettierd", + expect = "export function messy(a: number): number" }, + test = { file = "main.test.ts", bin = "npx" }, + debug = { file = "main.js", ft = "javascript", line = 2, adapter = "pwa-node", + config = "", var = "a", value = "40" }, + lint = { inject = "const __capBroken: number = \"not a number\";" }, + }, + java = { + bundle = "languages.java", dir = "java", + main = "src/main/java/capfixture/Main.java", ft = "java", + tool = "java", lsp = { "jdtls" }, parser = "java", node = "method_declaration", + run_file = "42", run_project = "42", + fmt = { file = "src/main/java/capfixture/Messy.java", bin = "google-java-format", + expect = "int messy(int a)" }, + test = { file = "src/test/java/capfixture/MainTest.java", bin = "mvn", + prepare = { { "mvn", "-q", "-DskipTests", "test-compile" } } }, + debug = { line = 5, adapter = "java", config = "", var = "a", value = "40" }, + lint = { inject = " int __capBroken() { return __capMissing(); }" }, + }, + c = { + bundle = "languages.c-cpp", dir = "c", main = "main.c", ft = "c", + tool = "cc", lsp = { "clangd" }, parser = "c", node = "function_definition", + run_file = "42", run_project = false, -- `make` builds; it does not run + fmt = { file = "messy.c", bin = "clang-format", expect = "int messy(int a)" }, + -- The test row uses the CMake/doctest fixture rather than the plain + -- Makefile one: CTest is what neotest-ctest drives, and a Makefile + -- project exposes no tests to it. + -- CTest lives in the same fixture root as the plain C files: neotest + -- roots an adapter at Neovim's cwd, so a test project in a sibling + -- directory is invisible to it. + -- GAP, not FAIL: the capability is real and passes on macOS, and on + -- Linux the project builds and `ctest -N` lists the tests while the + -- adapter reports nothing (issue #14). Tracked so a genuine regression + -- elsewhere stays visible. + test = { gap = "neotest-ctest yields no results on Linux (issue #14)", + file = "capfixture_test.cpp", bin = "ctest", prepare = { + { "cmake", "-S", ".", "-B", "build", "-DCMAKE_BUILD_TYPE=Debug" }, + { "cmake", "--build", "build" }, + }, + -- Asked only when the cell fails, and it separates the three things + -- that look identical from the outside: the project did not build, or + -- ctest cannot see the tests, or the adapter cannot see ctest. + diagnose = { "ctest", "--test-dir", "build", "-N" } }, + -- c-cpp's launch config asks for the executable path with vim.fn.input, + -- so the probe answers it the way a user would. + -- Built to its own path, not `main`: checkpoint 7 compiles main.c to + -- `main` without -g, and `make main` then sees it up to date and skips + -- the debug build, so the breakpoint never binds and the program runs + -- to completion. Two checkpoints must not share one artifact. + -- Line 4, the first statement of the body, not line 3, the signature. + -- A breakpoint on a function's opening line binds inside the prologue, + -- before the parameters reach their stack slots, and reads garbage: on + -- Linux this produced `a=32767` while macOS happened to read 40. + debug = { line = 4, adapter = "codelldb", config = "", var = "a", value = "40", + program = "main_debug", + build = { "cc", "-g", "-O0", "main.c", "-o", "main_debug" } }, + lint = { inject = "int __cap_broken(void) { return __cap_missing(); }" }, + }, +} + +local spec = SPECS[LANG] +if not spec then + print("FAIL: unknown CAP_LANG '" .. LANG .. "'") + vim.cmd("cq1") + return +end + +print(("=== %s (%s) ==="):format(LANG, spec.bundle)) + +local fixture = ROOT .. "/" .. spec.dir +local target = fixture .. "/" .. spec.main + +if vim.fn.executable(spec.tool) ~= 1 then + -- Every checkpoint, not just the four the else-branch opens with. Listing a + -- subset here silently dropped format, diagnostics, test and debug from the + -- report whenever a toolchain was absent, which is exactly the "a cell may + -- never be skipped" rule this file claims to follow. + for _, cp in ipairs({ "1 lsp", "2 treesitter", "3 format", "4 diagnostics", + "5 test", "6 debug", "7 run-file", "8 run-project" }) do + record(cp, "UNCOVERED", spec.tool .. " not on PATH") + end +else + vim.cmd("edit " .. vim.fn.fnameescape(target)) + local buf = vim.api.nvim_get_current_buf() + + -- ── 1. LSP attaches ─────────────────────────────────────────────────── + -- Red when: no client with an expected name attaches inside the budget. + local attached + local ok_lsp = poll(60000, function() + for _, c in ipairs(vim.lsp.get_clients({ bufnr = buf })) do + for _, want in ipairs(spec.lsp) do + if c.name == want then attached = c.name; return true end + end + end + return false + end) + record("1 lsp", ok_lsp and "PASS" or "FAIL", + ok_lsp and attached or ("expected one of " .. table.concat(spec.lsp, "/"))) + + -- ── 2. Treesitter parses it ─────────────────────────────────────────── + -- Red when: the parser is not installed, or the tree carries no node of + -- the type this language must produce for the fixture. + local ok_parser = poll(60000, function() + return #vim.api.nvim_get_runtime_file("parser/" .. spec.parser .. ".so", false) > 0 + end) + if not ok_parser then + record("2 treesitter", "FAIL", "parser/" .. spec.parser .. ".so never installed") + else + local got_node = false + local ok_p, parser = pcall(vim.treesitter.get_parser, buf, spec.parser) + if ok_p and parser then + local tree = parser:parse()[1] + local function walk(node) + if got_node then return end + if node:type() == spec.node then got_node = true; return end + for child in node:iter_children() do walk(child) end + end + walk(tree:root()) + end + record("2 treesitter", got_node and "PASS" or "FAIL", + got_node and (spec.parser .. " -> " .. spec.node) + or ("no " .. spec.node .. " node found")) + end + + -- ── 7 / 8. Run the file, run the project ────────────────────────────── + -- Red when: no command is produced, it exits non-zero, or its stdout is + -- not what the fixture prints. Building the command is not the claim; + -- running it is. + local run = require("noethervim.util.run") + local function check_run(kind, cp, expected) + if expected == false then + record(cp, "N/A", "no project-level run convention for " .. LANG) + return + end + local cmd, cwd = run.command(kind, buf) + if not cmd or not cwd then + record(cp, "FAIL", "util.run produced no " .. kind .. " command") + return + end + local res = vim.system({ "sh", "-c", cmd }, { cwd = cwd, text = true }):wait(120000) + local out = vim.trim(res.stdout or "") + if res.code ~= 0 then + record(cp, "FAIL", ("exit %d: %s"):format(res.code, + vim.trim((res.stderr or ""):gsub("%s+", " ")):sub(1, 90))) + elseif out ~= expected then + record(cp, "FAIL", ("stdout %q, expected %q"):format(out:sub(1, 40), expected)) + else + record(cp, "PASS", cmd:sub(1, 60)) + end + end + check_run("file", "7 run-file", spec.run_file) + check_run("project", "8 run-project", spec.run_project) + + -- ── 3. Formatter ────────────────────────────────────────────────────── + -- Two assertions, because either alone passes for the wrong reason. That + -- the buffer CHANGED catches the common bug (a filetype claimed in + -- `formatters_by_ft` whose binary was never installed, where conform + -- silently falls back to LSP formatting and the reader believes the + -- formatter ran). That the result CONTAINS the normalised text catches + -- the wrong formatter having run. + -- + -- Formatted in a scratch copy: the fixture must stay misformatted so the + -- next run has something to reformat. + do + local f = spec.fmt + -- "On PATH" is not the same as "works": TeX Live's latexindent is a + -- Perl script that fails to load its own modules against a newer Perl, + -- and reporting that as a bundle FAIL would blame the wrong thing. A + -- binary that cannot run at all is an environment gap. + -- Opt-in, not default: `--version` is not universal (goimports exits + -- non-zero on it), and defaulting this on turned a working formatter + -- into a false UNCOVERED. Only a binary known to be fragile asks for it. + local function binary_runs() + if not f.smoke then return true end + local r = vim.system({ f.bin, "--version" }, { text = true }):wait(20000) + return r.code == 0 + end + if vim.fn.executable(f.bin) ~= 1 then + record("3 format", "UNCOVERED", f.bin .. " not on PATH (Mason install did not land)") + elseif not binary_runs() then + record("3 format", "UNCOVERED", f.bin .. " is on PATH but fails to run here") + else + local src = fixture .. "/" .. f.file + local tmp = vim.fn.tempname() .. "_" .. vim.fn.fnamemodify(f.file, ":t") + vim.fn.writefile(vim.fn.readfile(src), tmp) + + vim.cmd("edit " .. vim.fn.fnameescape(tmp)) + local fbuf = vim.api.nvim_get_current_buf() + local before = table.concat(vim.api.nvim_buf_get_lines(fbuf, 0, -1, false), "\n") + local ok_fmt, err = pcall(function() + require("conform").format({ bufnr = fbuf, async = false, timeout_ms = 20000 }) + end) + local after = table.concat(vim.api.nvim_buf_get_lines(fbuf, 0, -1, false), "\n") + + if not ok_fmt then + record("3 format", "FAIL", "conform.format errored: " .. tostring(err)) + elseif after == before then + record("3 format", "FAIL", "buffer unchanged (formatter did not run)") + elseif not after:find(f.expect, 1, true) then + record("3 format", "FAIL", ("expected %q in the result"):format(f.expect)) + else + record("3 format", "PASS", f.bin .. " -> " .. f.expect) + end + vim.cmd("bwipeout! " .. fbuf) + end + end + + -- ── 4. Diagnostics ──────────────────────────────────────────────────── + -- Every language in the contract takes diagnostics from its language + -- server, so this asserts the server reports a real defect rather than + -- that a separate linter exists. + -- + -- The defect is injected into the buffer and never written: a fixture that + -- does not compile would break the run checkpoints above, and every server + -- here reports on didChange rather than on save. Red when: the server + -- attaches but never reports the broken symbol. + if spec.lint.na then + record("4 diagnostics", "N/A", spec.lint.na) + elseif not ok_lsp then + record("4 diagnostics", "UNCOVERED", "no LSP attached; nothing to report diagnostics") + else + local lint_target = spec.lint.file and (fixture .. "/" .. spec.lint.file) or target + vim.cmd("edit! " .. vim.fn.fnameescape(lint_target)) + local dbuf = vim.api.nvim_get_current_buf() + + -- Languages whose diagnostics come from a linter rather than the server + -- need it kicked: nvim-lint fires on BufReadPost / BufWritePost / + -- InsertLeave, none of which a scripted open reliably produces. + pcall(function() require("lint").try_lint() end) + + -- Scoped to the injected lines, not "any diagnostic in the buffer". + -- A whole-buffer count survives its own break: remove the injection and + -- an unrelated pre-existing warning still satisfies it. Anchoring to + -- the lines we broke is what makes this red when the server stops + -- reporting. + local injected_from = 0 + if spec.lint.inject then + injected_from = vim.api.nvim_buf_line_count(dbuf) + vim.api.nvim_buf_set_lines(dbuf, -1, -1, false, vim.split(spec.lint.inject, "\n")) + end + + local function on_injected() + local hits = {} + for _, d in ipairs(vim.diagnostic.get(dbuf, { severity = { min = vim.diagnostic.severity.WARN } })) do + if d.lnum >= injected_from then hits[#hits + 1] = d end + end + return hits + end + + local ok_diag = poll(60000, function() return #on_injected() > 0 end) + local hits = on_injected() + record("4 diagnostics", ok_diag and "PASS" or "FAIL", + ok_diag and (("line %d %s: %s"):format(hits[1].lnum + 1, hits[1].source or "?", + (hits[1].message or ""):gsub("%s+", " "):sub(1, 48))) + or ("no diagnostic on the injected lines (from line %d)"):format(injected_from + 1)) + -- Leave the fixture on disk untouched. + vim.cmd("edit! " .. vim.fn.fnameescape(target)) + end + + -- ── 5. Test runner ──────────────────────────────────────────────────── + -- The fixture carries one passing and one failing test on purpose. Both + -- halves are asserted: discovery alone would pass on an adapter that finds + -- tests and never runs them, and "at least one passed" would pass on an + -- adapter that reports everything green. + -- + -- Red when: no adapter claims the file, no tests are discovered, or the + -- pass/fail split is not 1-and-1. + do + local t = spec.test + if t.na then + record("5 test", "N/A", t.na) + elseif t.gap then + record("5 test", "GAP", t.gap) + elseif vim.fn.executable(t.bin) ~= 1 then + record("5 test", "UNCOVERED", t.bin .. " not on PATH") + else + local test_fixture = ROOT .. "/" .. (t.dir or spec.dir) + -- Some runners need the project built before anything is + -- discoverable or runnable: CTest reads CTestTestfile.cmake from a + -- build dir, and neotest-java wants compiled test classes. + local prep_err + for _, cmd in ipairs(t.prepare or {}) do + local r = vim.system(cmd, { cwd = test_fixture, text = true }):wait(300000) + if r.code ~= 0 and not prep_err then + prep_err = ("%s exited %d: %s"):format(cmd[1], r.code, + vim.trim(((r.stderr or "") .. (r.stdout or "")):gsub("%s+", " ")):sub(1, 90)) + end + end + if t.prepare then vim.cmd("cd " .. vim.fn.fnameescape(test_fixture)) end + local test_path = test_fixture .. "/" .. t.file + vim.cmd("edit! " .. vim.fn.fnameescape(test_path)) + local tbuf = vim.api.nvim_get_current_buf() + + local ok_nt, neotest = pcall(require, "neotest") + local ok_cfg, ntcfg = pcall(require, "neotest.config") + if not ok_nt or not ok_cfg then + record("5 test", "FAIL", "neotest not loadable (tools/test not enabled?)") + elseif #(ntcfg.adapters or {}) == 0 then + record("5 test", "FAIL", "no neotest adapter configured for this bundle") + else + -- `state.adapter_ids()` is filled by the state tracker during + -- discovery, so it is empty until a run has started. Kick the + -- run first, then poll for both the id and its counts. + neotest.run.run(test_path) + + -- A bundle can register more than one adapter for a filetype: + -- web-dev registers both neotest-jest and neotest-vitest, and + -- only one of them owns a given project. Taking whichever + -- adapter_ids() happens to yield first made this cell flaky -- + -- green alone, red in the suite, because the idle adapter + -- reports total>0, running==0 and no results, which satisfies a + -- naive "the run finished" test. + -- + -- Require the positions to be resolved, not merely not-running. + local counts + local done = poll(300000, function() + for _, id in ipairs(neotest.state.adapter_ids()) do + local c = neotest.state.status_counts(id, { buffer = tbuf }) + if c and c.total > 0 and c.running == 0 + and (c.passed + c.failed) > 0 then + counts = c + return true + end + end + return false + end) + + if not done or not counts then + local names = {} + for _, a in ipairs(ntcfg.adapters) do names[#names + 1] = a.name or "?" end + -- A failed build presents as "no results", which blames the + -- adapter for something that happened before it ran. + local extra = "" + if not prep_err and t.diagnose then + local d = vim.system(t.diagnose, { cwd = test_fixture, text = true }):wait(60000) + extra = (" [%s -> exit %d: %s]"):format(t.diagnose[1], d.code, + vim.trim(((d.stdout or "") .. (d.stderr or "")):gsub("%s+", " ")):sub(1, 110)) + end + record("5 test", "FAIL", prep_err + and ("project did not build: " .. prep_err) + or ("no results within budget; configured: " + .. table.concat(names, ", ") .. extra)) + elseif counts.passed >= 1 and counts.failed >= 1 then + record("5 test", "PASS", ("%d passed, %d failed of %d") + :format(counts.passed, counts.failed, counts.total)) + else + record("5 test", "FAIL", ("expected >=1 passed and >=1 failed, got %d/%d of %d") + :format(counts.passed, counts.failed, counts.total)) + end + end + end + end + -- ── 6. Debugger ─────────────────────────────────────────────────────── + -- The claim is not "an adapter table exists" -- that is what made the Rust + -- debugger look fine while it registered zero configurations. The claim is + -- that a session starts, stops on a breakpoint in this file, and can read a + -- local. Anything less passes on a debugger that never runs. + -- + -- Red when: the adapter is unregistered, no configuration matches, the + -- session never stops, it stops on the wrong line, or the local reads wrong. + do + local d = spec.debug + local ok_dap, dap = pcall(require, "dap") + if d.na then + record("6 debug", "N/A", d.na) + elseif d.gap then + record("6 debug", "GAP", d.gap) + elseif not ok_dap then + record("6 debug", "FAIL", "nvim-dap not loadable (tools/debug not enabled?)") + elseif not dap.adapters[d.adapter] then + local names = vim.tbl_keys(dap.adapters) + table.sort(names) + record("6 debug", "FAIL", ("adapter %q not registered; have: %s") + :format(d.adapter, table.concat(names, ", "))) + else + -- Some fixtures need a binary on disk before anything can launch it. + if d.build then + vim.system(d.build, { cwd = fixture }):wait(180000) + end + + -- Checkpoint 5 may have left a session behind: neotest-ctest runs + -- tests through this same codelldb adapter, and dap.run() on top of + -- a live session does not produce a second stop. Start from a clean + -- debugger, and from no breakpoints, so `toggle` cannot toggle one + -- back off. + if dap.session() then + pcall(dap.terminate) + poll(15000, function() return dap.session() == nil end) + end + pcall(dap.clear_breakpoints) + + -- The debug row may use a different file from the rest: TypeScript + -- is stripped to JavaScript before it runs, so a breakpoint on the + -- .ts source needs sourcemaps the fixture does not build. Debugging + -- plain JavaScript is the bundle's baseline claim, so that is what + -- is asserted here. + local dtarget = fixture .. "/" .. (d.file or spec.main) + vim.cmd("edit! " .. vim.fn.fnameescape(dtarget)) + local dbuf = vim.api.nvim_get_current_buf() + + local configs = dap.configurations[d.ft or spec.ft] or {} + local chosen + for _, c in ipairs(configs) do + if d.config == "" or (c.name or ""):match(d.config) then chosen = c; break end + end + + if not chosen then + record("6 debug", "FAIL", ("no configuration for %s (%d present)") + :format(spec.ft, #configs)) + else + -- The C launch config asks for the executable with + -- `vim.fn.input`, which cannot be answered headlessly: `vim.fn` + -- is metatable-backed, so assigning a stub over it does not + -- take, and the real prompt then blocks until the budget runs + -- out. Supply the answer as config data instead: the same + -- value a user would type, without the prompt. + if d.program then + chosen = vim.tbl_extend("force", chosen, + { program = fixture .. "/" .. d.program }) + end + + vim.api.nvim_win_set_cursor(0, { d.line, 0 }) + dap.toggle_breakpoint() + + -- `current_frame` is filled by the stackTrace request nvim-dap + -- issues *after* the stopped event, so reading it inside the + -- listener sees nil. Record the stop, then poll for the frame. + local session_seen + dap.listeners.after.event_stopped["nv_capability"] = function() + session_seen = true + end + + -- The poll is wrapped, not just dap.run: an adapter that is + -- misconfigured fails inside an async callback, and that error + -- surfaces during vim.wait rather than at the call. Unwrapped it + -- kills the whole run and every later checkpoint with it. + local ok_run, run_err = pcall(dap.run, chosen) + local stopped, poll_err = false, nil + if ok_run then + local ok_poll, res = pcall(poll, 180000, function() + local s = dap.session() + return session_seen and s ~= nil and s.current_frame ~= nil + end) + if ok_poll then stopped = res else poll_err = res end + end + local sess = dap.session() + local stopped_line = stopped and sess and sess.current_frame and sess.current_frame.line or nil + + local detail + if not ok_run then + detail = "dap.run errored: " .. tostring(run_err):gsub("%s+", " "):sub(1, 80) + elseif poll_err then + detail = "adapter errored: " .. tostring(poll_err):gsub("%s+", " "):sub(1, 80) + elseif not stopped then + local s_now = dap.session() + detail = ("no stop (config %q): stopped_event=%s session=%s frame=%s") + :format(chosen.name or "?", tostring(session_seen ~= nil), + tostring(s_now ~= nil), tostring(s_now and s_now.current_frame ~= nil)) + elseif stopped_line ~= d.line then + detail = ("stopped on line %d, expected %d"):format(stopped_line, d.line) + end + + -- Read the local, so the assertion covers a usable session and + -- not merely a paused process. + local var_ok, var_detail = false, "" + if not detail then + local session = dap.session() + local frame = session and session.current_frame + local got + if session and frame then + session:request("scopes", { frameId = frame.id }, function(_, sres) + for _, scope in ipairs((sres or {}).scopes or {}) do + session:request("variables", { variablesReference = scope.variablesReference }, + function(_, vres) + for _, v in ipairs((vres or {}).variables or {}) do + if v.name == d.var then got = v.value end + end + end) + end + end) + poll(20000, function() return got ~= nil end) + end + var_ok = got ~= nil and tostring(got):find(d.value, 1, true) ~= nil + var_detail = ("%s=%s"):format(d.var, tostring(got)) + if not var_ok then detail = "local read wrong: " .. var_detail end + end + + pcall(dap.terminate) + poll(10000, function() return dap.session() == nil end) + dap.listeners.after.event_stopped["nv_capability"] = nil + + record("6 debug", detail and "FAIL" or "PASS", + detail or ("stopped line %d, %s"):format(stopped_line, var_detail)) + end + end + end +end + +print(("\n%s: %d passed, %d failed, %d gap, %d n/a, %d uncovered") + :format(LANG, pass, fail, gap, na, uncovered)) + +-- Machine-readable row for the matrix report. +local row = { LANG } +for _, c in ipairs(cells) do row[#row + 1] = c.cp .. "=" .. c.state end +print("MATRIX " .. table.concat(row, " ")) + +vim.cmd("cq" .. (fail > 0 and "1" or "0")) diff --git a/tests/capability_provision.lua b/tests/capability_provision.lua new file mode 100644 index 0000000..4106b98 --- /dev/null +++ b/tests/capability_provision.lua @@ -0,0 +1,85 @@ +-- tests/capability_provision.lua -- warm the isolated env before grading. +-- +-- Run by tests/capability.sh with the fixture already open, so that +-- nvim-lspconfig (lazy-loaded on BufReadPre) has actually loaded and +-- mason-lspconfig's `ensure_installed` has fired. Without opening a real file +-- of the language, no server is ever requested and a later "no LSP attached" +-- reads as a bundle defect rather than an unprovisioned harness. That is the +-- coverage-honesty rule from full-toolchain-sweep-plan.md: a failure must never +-- be a hidden missing tool. +-- +-- Mason 2 exposes no "what is installing" query, so rather than guess at an +-- API, wait for the installed-package set to stop growing: the queue is drained +-- when nothing new has landed for QUIET_MS. + +local BUDGET_MS = tonumber(vim.env.CAP_PROVISION_BUDGET or "") or 600000 +local QUIET_MS = 15000 + +-- conform loads on BufWritePre and nvim-dap on a keypress, so simply opening a +-- fixture leaves both unloaded and their `mason_install` lists unread: the +-- formatter and the debug adapter would then be missing for reasons that have +-- nothing to do with the bundle. Force them in before waiting. +pcall(function() + require("lazy").load({ plugins = { "conform.nvim", "nvim-lint", "nvim-dap" } }) +end) + +local ok, registry = pcall(require, "mason-registry") +if not ok then + io.stderr:write("provision: mason-registry unavailable; nothing to wait for\n") + vim.cmd("qa!") + return +end + +-- mason-lspconfig skips `ensure_installed` outright when Neovim is headless +-- (mason-lspconfig/init.lua: `if not platform.is_headless and ...`), so a +-- headless harness can never provision a language server the way a real +-- session does. Do it here instead, by the same route its feature module +-- takes: map the lspconfig server name to a Mason package and install it. +-- +-- This provisions the harness; it does not test the distro. Whether a real +-- session installs the server is a question only an interactive run can +-- answer, and it is recorded as such in the plan. +do + local ok_lazy, lazy_cfg = pcall(require, "lazy.core.config") + local ok_map, mappings = pcall(require, "mason-lspconfig.mappings") + if ok_lazy and ok_map then + local plugin = lazy_cfg.plugins["nvim-lspconfig"] + local opts = plugin and require("lazy.core.plugin").values(plugin, "opts") or {} + local to_package = mappings.get_mason_map().lspconfig_to_package + for _, server in ipairs(opts.ensure_installed or {}) do + local pkg_name = to_package[server] + if pkg_name and registry.has_package(pkg_name) then + local pkg = registry.get_package(pkg_name) + if not pkg:is_installed() then + io.stderr:write(("provision: installing %s (%s)\n"):format(server, pkg_name)) + pkg:install() + end + else + io.stderr:write(("provision: no Mason package for server %q\n"):format(server)) + end + end + end +end + +local function installed() + local names = registry.get_installed_package_names() + table.sort(names) + return names +end + +local last, last_change, waited = table.concat(installed(), ","), 0, 0 +while waited < BUDGET_MS do + vim.wait(1000, function() return false end, 100) + waited = waited + 1000 + + local now = table.concat(installed(), ",") + if now ~= last then + last, last_change = now, waited + elseif waited - last_change >= QUIET_MS then + break + end +end + +io.stderr:write(("provision: %ds, installed: %s\n") + :format(waited / 1000, table.concat(installed(), " "))) +vim.cmd("qa!") diff --git a/tests/fixtures/lang/c/CMakeLists.txt b/tests/fixtures/lang/c/CMakeLists.txt new file mode 100644 index 0000000..7fbae46 --- /dev/null +++ b/tests/fixtures/lang/c/CMakeLists.txt @@ -0,0 +1,15 @@ +cmake_minimum_required(VERSION 3.21) +project(capfixture CXX) +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_EXPORT_COMPILE_COMMANDS ON) + +enable_testing() +add_executable(tests capfixture_test.cpp) +target_include_directories(tests PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) + +# Registered by hand rather than via doctest_discover_tests: the ctest test +# names must equal the TEST_CASE names for neotest-ctest to map a result back +# to a position in the source, and doing it here keeps the fixture to two +# files with no generated helper script. +add_test(NAME passes COMMAND tests --test-case=passes) +add_test(NAME fails COMMAND tests --test-case=fails) diff --git a/tests/fixtures/lang/c/Makefile b/tests/fixtures/lang/c/Makefile new file mode 100644 index 0000000..e78bd54 --- /dev/null +++ b/tests/fixtures/lang/c/Makefile @@ -0,0 +1,5 @@ +main: main.c + cc -g main.c -o main + +run: main + ./main diff --git a/tests/fixtures/lang/c/capfixture_test.cpp b/tests/fixtures/lang/c/capfixture_test.cpp new file mode 100644 index 0000000..085f639 --- /dev/null +++ b/tests/fixtures/lang/c/capfixture_test.cpp @@ -0,0 +1,12 @@ +#define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN +#include "doctest.h" + +int add(int a, int b) { return a + b; } + +TEST_CASE("passes") { + CHECK(add(1, 2) == 3); +} + +TEST_CASE("fails") { + CHECK(add(1, 2) == 4); +} diff --git a/tests/fixtures/lang/c/main.c b/tests/fixtures/lang/c/main.c new file mode 100644 index 0000000..b0d279c --- /dev/null +++ b/tests/fixtures/lang/c/main.c @@ -0,0 +1,11 @@ +#include + +int add(int a, int b) { + int sum = a + b; + return sum; +} + +int main(void) { + printf("%d\n", add(40, 2)); + return 0; +} diff --git a/tests/fixtures/lang/c/messy.c b/tests/fixtures/lang/c/messy.c new file mode 100644 index 0000000..2ed9a07 --- /dev/null +++ b/tests/fixtures/lang/c/messy.c @@ -0,0 +1 @@ +int messy( int a ){return a+1;} diff --git a/tests/fixtures/lang/cpp/Makefile b/tests/fixtures/lang/cpp/Makefile new file mode 100644 index 0000000..f8be975 --- /dev/null +++ b/tests/fixtures/lang/cpp/Makefile @@ -0,0 +1,5 @@ +main: main.cpp + c++ -g main.cpp -o main + +run: main + ./main diff --git a/tests/fixtures/lang/cpp/main.cpp b/tests/fixtures/lang/cpp/main.cpp new file mode 100644 index 0000000..a822e6d --- /dev/null +++ b/tests/fixtures/lang/cpp/main.cpp @@ -0,0 +1,11 @@ +#include + +int add(int a, int b) { + int sum = a + b; + return sum; +} + +int main() { + std::cout << add(40, 2) << std::endl; + return 0; +} diff --git a/tests/fixtures/lang/cpp/messy.cpp b/tests/fixtures/lang/cpp/messy.cpp new file mode 100644 index 0000000..2ed9a07 --- /dev/null +++ b/tests/fixtures/lang/cpp/messy.cpp @@ -0,0 +1 @@ +int messy( int a ){return a+1;} diff --git a/tests/fixtures/lang/go/go.mod b/tests/fixtures/lang/go/go.mod new file mode 100644 index 0000000..fc50c44 --- /dev/null +++ b/tests/fixtures/lang/go/go.mod @@ -0,0 +1,3 @@ +module capfixture + +go 1.21 diff --git a/tests/fixtures/lang/go/main.go b/tests/fixtures/lang/go/main.go new file mode 100644 index 0000000..94176fb --- /dev/null +++ b/tests/fixtures/lang/go/main.go @@ -0,0 +1,7 @@ +package main + +import "fmt" + +func Add(a, b int) int { return a + b } + +func main() { fmt.Println(Add(40, 2)) } diff --git a/tests/fixtures/lang/go/main_test.go b/tests/fixtures/lang/go/main_test.go new file mode 100644 index 0000000..2ba301d --- /dev/null +++ b/tests/fixtures/lang/go/main_test.go @@ -0,0 +1,15 @@ +package main + +import "testing" + +func TestPasses(t *testing.T) { + if Add(1, 2) != 3 { + t.Fatal("expected 3") + } +} + +func TestFails(t *testing.T) { + if Add(1, 2) != 4 { + t.Fatal("expected 4") + } +} diff --git a/tests/fixtures/lang/go/messy.go b/tests/fixtures/lang/go/messy.go new file mode 100644 index 0000000..d264a42 --- /dev/null +++ b/tests/fixtures/lang/go/messy.go @@ -0,0 +1,3 @@ +package main + +func Messy( a int )int{return a+1} diff --git a/tests/fixtures/lang/java/pom.xml b/tests/fixtures/lang/java/pom.xml new file mode 100644 index 0000000..cfb2662 --- /dev/null +++ b/tests/fixtures/lang/java/pom.xml @@ -0,0 +1,37 @@ + + + 4.0.0 + capfixture + capfixture + 1.0 + + 17 + 17 + UTF-8 + + + + org.junit.jupiter + junit-jupiter + 5.10.2 + test + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.2.5 + + + org.codehaus.mojo + exec-maven-plugin + 3.1.1 + capfixture.Main + + + + diff --git a/tests/fixtures/lang/java/src/main/java/capfixture/Main.java b/tests/fixtures/lang/java/src/main/java/capfixture/Main.java new file mode 100644 index 0000000..59fa8a3 --- /dev/null +++ b/tests/fixtures/lang/java/src/main/java/capfixture/Main.java @@ -0,0 +1,11 @@ +package capfixture; + +public class Main { + public static int add(int a, int b) { + return a + b; + } + + public static void main(String[] args) { + System.out.println(add(40, 2)); + } +} diff --git a/tests/fixtures/lang/java/src/main/java/capfixture/Messy.java b/tests/fixtures/lang/java/src/main/java/capfixture/Messy.java new file mode 100644 index 0000000..abae196 --- /dev/null +++ b/tests/fixtures/lang/java/src/main/java/capfixture/Messy.java @@ -0,0 +1,5 @@ +package capfixture; + +public class Messy { + public static int messy( int a ){return a+1;} +} diff --git a/tests/fixtures/lang/java/src/test/java/capfixture/MainTest.java b/tests/fixtures/lang/java/src/test/java/capfixture/MainTest.java new file mode 100644 index 0000000..554a377 --- /dev/null +++ b/tests/fixtures/lang/java/src/test/java/capfixture/MainTest.java @@ -0,0 +1,17 @@ +package capfixture; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; + +class MainTest { + @Test + void passes() { + assertEquals(3, Main.add(1, 2)); + } + + @Test + void fails() { + assertEquals(4, Main.add(1, 2)); + } +} diff --git a/tests/fixtures/lang/latex/main.tex b/tests/fixtures/lang/latex/main.tex new file mode 100644 index 0000000..9646a64 --- /dev/null +++ b/tests/fixtures/lang/latex/main.tex @@ -0,0 +1,8 @@ +\documentclass{article} + +\begin{document} + +\section{Introduction} +The answer is 42. + +\end{document} diff --git a/tests/fixtures/lang/latex/messy.tex b/tests/fixtures/lang/latex/messy.tex new file mode 100644 index 0000000..68d65cd --- /dev/null +++ b/tests/fixtures/lang/latex/messy.tex @@ -0,0 +1,7 @@ +\documentclass{article} +\begin{document} +\section{Messy} +\begin{itemize} +\item one +\end{itemize} +\end{document} diff --git a/tests/fixtures/lang/python/main.py b/tests/fixtures/lang/python/main.py new file mode 100644 index 0000000..87ea6b6 --- /dev/null +++ b/tests/fixtures/lang/python/main.py @@ -0,0 +1,6 @@ +def add(a: int, b: int) -> int: + return a + b + + +if __name__ == "__main__": + print(add(40, 2)) diff --git a/tests/fixtures/lang/python/messy.py b/tests/fixtures/lang/python/messy.py new file mode 100644 index 0000000..5f6b40a --- /dev/null +++ b/tests/fixtures/lang/python/messy.py @@ -0,0 +1,2 @@ +def messy( a ): + return a+1 diff --git a/tests/fixtures/lang/python/pyproject.toml b/tests/fixtures/lang/python/pyproject.toml new file mode 100644 index 0000000..e8f1649 --- /dev/null +++ b/tests/fixtures/lang/python/pyproject.toml @@ -0,0 +1,3 @@ +[project] +name = "capfixture" +version = "0.1.0" diff --git a/tests/fixtures/lang/python/test_main.py b/tests/fixtures/lang/python/test_main.py new file mode 100644 index 0000000..bf81140 --- /dev/null +++ b/tests/fixtures/lang/python/test_main.py @@ -0,0 +1,9 @@ +from main import add + + +def test_passes(): + assert add(1, 2) == 3 + + +def test_fails(): + assert add(1, 2) == 4 diff --git a/tests/fixtures/lang/rust/Cargo.toml b/tests/fixtures/lang/rust/Cargo.toml new file mode 100644 index 0000000..7a1d75e --- /dev/null +++ b/tests/fixtures/lang/rust/Cargo.toml @@ -0,0 +1,4 @@ +[package] +name = "capfixture" +version = "0.1.0" +edition = "2021" diff --git a/tests/fixtures/lang/rust/src/main.rs b/tests/fixtures/lang/rust/src/main.rs new file mode 100644 index 0000000..4aee8dc --- /dev/null +++ b/tests/fixtures/lang/rust/src/main.rs @@ -0,0 +1,23 @@ +mod messy; +fn add(a: i32, b: i32) -> i32 { + a + b +} + +fn main() { + println!("{}", add(40, 2) + messy::messy(0) - 1); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn passes() { + assert_eq!(add(1, 2), 3); + } + + #[test] + fn fails() { + assert_eq!(add(1, 2), 4); + } +} diff --git a/tests/fixtures/lang/rust/src/messy.rs b/tests/fixtures/lang/rust/src/messy.rs new file mode 100644 index 0000000..7a3a3cb --- /dev/null +++ b/tests/fixtures/lang/rust/src/messy.rs @@ -0,0 +1 @@ +pub fn messy ( a:i32 )->i32{a+1} diff --git a/tests/fixtures/lang/web/main.js b/tests/fixtures/lang/web/main.js new file mode 100644 index 0000000..d70b577 --- /dev/null +++ b/tests/fixtures/lang/web/main.js @@ -0,0 +1,5 @@ +export function add(a, b) { + return a + b; +} + +console.log(add(40, 2)); diff --git a/tests/fixtures/lang/web/main.test.ts b/tests/fixtures/lang/web/main.test.ts new file mode 100644 index 0000000..dbabdb4 --- /dev/null +++ b/tests/fixtures/lang/web/main.test.ts @@ -0,0 +1,10 @@ +import { expect, test } from "vitest"; +import { add } from "./main"; + +test("passes", () => { + expect(add(1, 2)).toBe(3); +}); + +test("fails", () => { + expect(add(1, 2)).toBe(4); +}); diff --git a/tests/fixtures/lang/web/main.ts b/tests/fixtures/lang/web/main.ts new file mode 100644 index 0000000..10ff001 --- /dev/null +++ b/tests/fixtures/lang/web/main.ts @@ -0,0 +1,5 @@ +export function add(a: number, b: number): number { + return a + b; +} + +console.log(add(40, 2)); diff --git a/tests/fixtures/lang/web/messy.ts b/tests/fixtures/lang/web/messy.ts new file mode 100644 index 0000000..c8145b8 --- /dev/null +++ b/tests/fixtures/lang/web/messy.ts @@ -0,0 +1 @@ +export function messy( a:number ):number{return a+1} diff --git a/tests/fixtures/lang/web/package.json b/tests/fixtures/lang/web/package.json new file mode 100644 index 0000000..36ddbae --- /dev/null +++ b/tests/fixtures/lang/web/package.json @@ -0,0 +1,7 @@ +{ + "name": "capfixture", + "version": "1.0.0", + "type": "module", + "scripts": { "start": "node main.ts", "test": "vitest run" }, + "devDependencies": { "vitest": "^2.1.8" } +}