Instructions for AI agents working on the neotest-java codebase — a Neovim plugin (Lua) that integrates with neotest to discover and run Java tests.
make # full install + test cycle (clones deps on first run)
make test # unit suite via scripts/testTest runner: scripts/test → nvim --headless -u scripts/minimal_init.lua -c "lua MiniTest.run()". Single file: bash scripts/test tests/unit/<file>_spec.lua. Single test inside a file: not supported — narrow the file's describe/it blocks.
Lint / format:
stylua --check . # format check (CI runs this)
luacheck lua/neotest-java/... # standard luacheck
lua-language-server --check . # sumneko/lua-language-server diagnostics (CI)lua/neotest-java/
init.lua # entry point — NeotestJavaAdapter, wires all sub-modules
build_tool/ # Maven + Gradle build-tool configs
command/ # junit launcher, command executor
core/
file_checker.lua # matches test file names against config patterns
positions_discoverer.lua
result_builder.lua # orchestrates the test-result flow
spec_builder/ # builds the nvim LSP spec for neotest
root_finder.lua
junit_result_reader.lua # walks JUnit XML and produces JunitResult objects
model/
junit_result.lua # the JunitResult class
path.lua # neotest-java.Path — wrap ALL file paths in this
util/
xml_reader.lua # generic XML reader, injectable read_file/xml_parse
read_xml_tag.lua # (REMOVED — superseded by xml_reader.read_tag)
checksums, dir_scan, etc.
Every component that takes dependencies is a function that returns an instance table — NOT a class, NOT a metatable. The function is named with a capital-letter, takes a single deps table, and returns the public surface.
-- definition
local MyComponent = function(deps)
deps = deps or {}
-- fill defaults from real libs
deps.foo = deps.foo or require("foo")
deps.bar = deps.bar or function() end
return {
do_thing = function()
return deps.foo() .. deps.bar()
end,
}
end
-- usage (at the call site, typically init.lua)
local c = MyComponent({ foo = my_stub, bar = my_counter })
c.do_thing()Live examples: XmlReader, JunitResultReader, ResultBuilder, FileChecker, SpecBuilder. Always match this style for new components.
When a component has defaults, the constructor merges per-field, not by replacing the whole table:
local defaults = default_deps() -- lazy-loads heavy libs
deps.read_file = deps.read_file or defaults.read_file
deps.xml_parse = deps.xml_parse or defaults.xml_parseThis lets callers inject a subset (e.g., just read_file) and inherit the rest.
Always wrap file paths in neotest-java.model.path — never use raw strings. This keeps the codebase cross-platform (Windows uses \\).
local Path = require("neotest-java.model.path")
local p = Path("/foo/bar.xml")
print(tostring(p)) -- "/foo/bar.xml" on Unix, "\\foo\\bar.xml" on WindowsIn tests, use Path("/fake/path") for stub paths and key stub lookup tables by tostring(path) — Lua tables use raw equality for keys, so two distinct Path instances with the same stringification would NOT match as keys. The Path __eq metamethod doesn't apply to table indexing.
This project uses sumneko/lua-language-server annotations. Match the existing style:
--- @class neotest-java.MyClass
--- @field method fun(arg: string): boolean
--- @param deps neotest-java.MyClassDeps | nil
--- @return neotest-java.MyClass
local MyClass = function(deps) ... endneotest.Logger is a class with many required fields. For test stubs that only implement debug/warn, use a duck-typed union in your dep type:
--- @field log? neotest.Logger | { debug: fun(...), warn: fun(...) }Same trick for XmlReader stubs that only implement parse:
--- @field xml_reader? neotest-java.XmlReader | { parse: fun(filepath: neotest-java.Path | string): { tree: table, error: string } }This keeps the CI lua-language-server check passing while allowing focused test doubles.
- Unit specs (
tests/unit/test_<module>_spec.lua) — drive the module with stub dependencies. No real I/O, no async context. Each test runs in single-digit ms. - Social specs (
tests/unit/test_<module>_social_spec.lua, optional) — wire a real<module>against stub lower-level collaborators to exercise integration without the full I/O stack. Use this when you want to verify realistic tree shapes, parser quirks, or cross-component contracts.
Both use mini.test (busted-compatible: describe/it/before_each/after_each). Assert with the project's tests.assertions.eq (deep equality with diff).
If a test needs the nio event loop, use the helper from tests.async_helpers:
local async = require("tests.async_helpers").async
it("does the thing", async(function()
-- nio context available here
end))async(fn) is just a wrapper that returns a function for it's second arg. Do not call it directly (async(fn)() is wrong) — it invokes the returned function inside the test runner.
This project uses OpenSpec for change management. The openspec/changes/ directory holds active changes; openspec/changes/archive/ holds archived ones; openspec/specs/ holds the project's durable capability specs.
openspec/
specs/ # main capability specs (durable record)
xml-reader/spec.md
junit-result-reader/spec.md
build-tool/spec.md
changes/
<active-change-name>/
proposal.md # WHY
design.md # HOW
specs/<capability>/spec.md # delta spec (ADDED/MODIFIED/REMOVED)
tasks.md # implementation checklist
archive/YYYY-MM-DD-<name>/ # archived
Workflow:
/opsx-propose <name>— scaffold artifacts/opsx-apply <name>— implement tasksopenspec archive <name>— promote delta spec toopenspec/specs/and move the change to archive
openspec/ is gitignored locally (via .git/info/exclude, the developer's per-machine file). The team's convention is to NOT commit openspec/ files. When you need to commit a new openspec/ artifact (e.g., to push a draft PR or document an archive), use git add -f to bypass.
.gitignore has openspec/changes/archive/ so archived changes are never tracked. Force-add only the new main spec at openspec/specs/<capability>/spec.md (not the archive contents).
feat/<kebab-case-name>for new functionalityrefactor/<kebab-case-name>for refactors- One PR per change; cut a new branch from latest
mainfor each
Conventional Commits, scoped to the module(s) touched:
feat(xml): add testable XmlReader module
test(xml): add XmlReader spec covering all 9 scenarios from the spec
refactor(result_builder): use JunitResultReader as dep
fix(xml): mark XmlReaderDeps fields as optional in type annotation
chore(openspec): archive refactor-xml-reader and promote xml-reader spec
- Atomic commits: one logical change per commit. A "logical change" is roughly: new module + its tests, OR one migration step, OR one bug fix. The apply phase should produce a sequence of well-named commits that read top-to-bottom as a story.
- Commit early, commit often. A PR with 8 atomic commits is easier to review than one with 8 changes in a single commit.
.pre-commit-config.yaml runs these on every commit and push:
- commitizen (commit-msg format check)
- stylua (Lua format)
- luacheck (Lua lint)
- markdownlint (
--fix, only on*.mdfiles) make test(the full unit suite)
For openspec/ .md files: the OpenSpec schema requires H2 as the first heading and long lines, which conflicts with the repo's markdownlint rules (MD041, MD013). Bypass with --no-verify on the commit and the push.
For markdown content in openspec/ files: prefer git commit --no-verify over fighting the hook. The hook does --fix and may corrupt your content before failing.
-
require("neotest-java.util.xml_reader")returns a TABLE (the module exports{ new = XmlReader, read_tag = ... }). The constructor is.new. Forgetting the.newgivesattempt to call (a table value). -
JunitResult:new(testcase, tempname)expectstempname: fun(): string, not a string. The function is called later byJunitResult:result(). Pass the function, not the result of calling it. -
The
nioruntime is mocked in tests (seescripts/minimal_init.lua— patchesnio.schedulerto a no-op, patchesnio.tests.with_timeoutto usefast_only=true). If a test needs the event loop, use theasyncwrapper fromtests.async_helpers. -
vim.uv.os_tmpdir()is the cross-platform temp dir, but unit tests should use a fixedTEMPNAMEfromos.getenv("TEMP") or os.getenv("TMP") or vim.uv.os_tmpdir()so assertions are stable. -
make installclones the test deps (mini.nvim, plenary, neotest, etc.) into./.dependencies/. If the C compiler for tree-sitter-java times out on Windows CI, that's a pre-existing CI flake — not a code bug. -
Run
lua-language-server --check .locally before pushing. It's part of CI and is the most common cause of a "ready to merge → checks fail" loop. -
The unit test suite runs in ~0.12s (142+ cases). If a single test takes >5s, something is wrong — usually a missing
asyncwrapper or a forgotten real I/O call in a stub.