A Java productivity layer for Neovim, on top of an externally managed jdtls.
Code generation · class creation · test runner · build runner · refactorings · debugging
jc.nvim never starts or installs the language server. You run jdtls with
nvim-java,
nvim-jdtls or nvim-lspconfig, and
jc.nvim hooks into whatever jdtls client attaches and adds the ergonomics of
vim-javacomplete2 (its
predecessor), rebuilt on Neovim's built-in LSP client.
Table of contents
- 🛠️ Code generation —
toString,hashCode/equals, constructors, accessors, all with interactive field selection; add unimplemented (abstract) methods. - 📦 Organize imports — a smart mode that remembers your preferred class per
ambiguous name, per project; remove-unused / add-missing without reordering
the rest; replace an import by picking among same-named types; pick an IDE
sort preset (Eclipse / IntelliJ IDEA / VS Code / Google), remembered per
project; add an annotation to a method or class by a name prefix (
Get→Getter/GetMapping/…, picks and imports the chosen type;Service springnarrows same-named types by package) — a live telescope picker when available, otherwise a prompt +vim.ui.select. - 🏗️ Class creation — a one-line DSL (or a step-by-step wizard) with
<Tab>completion, project-aware package/module resolution and a library of templates (records, spring stereotypes, JPA entity, JUnit, …). - 🧪 Test runner — run JUnit tests through neotest with the classpath resolved from jdtls (debug via nvim-jdtls/nvim-java); optional.
- ⚙️ Build runner — run gradle/maven tasks with a module + task picker; compile errors go to the quickfix list.
- 🔧 Refactorings — extract variable / method, convert to static import,
flip the receiver and argument of a call (
a.equals(b)→b.equals(a)), move a class to another package (references updated). - 🧷 Snippets — an optional VS Code snippet set (field/modifier combos, NetBeans-style abbreviations) for your snippet engine.
- 🐞 Debugging — attach/launch via nvim-dap or vimspector, with per-project host/port memory.
- 🧭 Navigation — jump between a class and its test; go to a file by its
fully-qualified name (an FQN-aware
gf). - 🧰 Utilities — classpath-aware
javap/jshell/jol; decompiledjdt://class view; wipe a corrupted jdtls workspace.
Generating a constructor and toString — the picker windows let you pick the
fields and the style:
Adding an annotation by name — a live search over jdtls' types inserts the
@Annotation and its import:
Replacing an import — pick a different same-named type and the import line is swapped in place:
- Neovim ≥ 0.10 (0.11+ recommended).
- A running
jdtlsfrom nvim-java, nvim-jdtls or lspconfig, started withextendedClientCapabilities(notablyexecuteClientCommandSupportandadvancedOrganizeImportsSupport) — nvim-java and nvim-jdtls do this out of the box. - Optional, per feature:
- debug — the java-debug
bundle in jdtls (nvim-java bundles it; nvim-jdtls: add it to
init_options.bundles) and nvim-dap or vimspector. - test runner — neotest (see Test runner).
:JCutilJoldownloads the jol-cli jar into~/.m2on first use.
- debug — the java-debug
bundle in jdtls (nvim-java bundles it; nvim-jdtls: add it to
lazy.nvim, jdtls managed by nvim-java (recommended)
return {
"artur-shaik/jc.nvim",
ft = { "java" },
dependencies = { "nvim-java/nvim-java" },
opts = {
keys_prefix = "<leader>j",
},
}With the optional test runner (neotest)
{
"artur-shaik/jc.nvim",
ft = { "java" },
dependencies = {
"nvim-java/nvim-java",
{
"nvim-neotest/neotest",
optional = true,
dependencies = { "nvim-neotest/nvim-nio", "nvim-lua/plenary.nvim" },
opts = function(_, opts)
opts.adapters = opts.adapters or {}
table.insert(opts.adapters, require("jc").neotest_adapter())
-- optional: auto-close the summary on an all-green focused run
opts.consumers = opts.consumers or {}
opts.consumers.jc = require("jc").neotest_consumer()
end,
},
},
opts = { keys_prefix = "<leader>j" },
}jc.nvim works with any owner of the jdtls client — nvim-jdtls or a plain
lspconfig setup are fine too; just drop nvim-java from dependencies and
start jdtls your own way.
If setup is never called, opening a java file initializes the plugin with
defaults.
All options go through setup(opts) (or your plugin manager's opts):
require("jc").setup({
keys_prefix = "<leader>j", -- prefix for the default mappings
default_mappings = true, -- install default mappings on attach
autoformat_on_save = false, -- format java buffers on save
debug_backend = nil, -- "dap" | "vimspector" | nil (auto-detect)
basedir = nil, -- data dir, default ~/.local/share/jc.nvim
update_config_on_new_file = true, -- refresh jdtls build path on new java files
templates_dir = nil, -- dir of user class templates
class_type_exclude = nil, -- package prefixes hidden from type completion
class_prompt = "oneline", -- "oneline" (DSL) | "wizard" (step-by-step)
map_gf = true, -- override gf with an FQN-aware go-to-file
on_attach = nil, -- function(client, bufnr) extra hook
test = { -- test runner (see Test runner)
precompile = false, -- compile with gradle/maven before a run
precompile_exclude = nil, -- gradle tasks to keep out of it, e.g. { "generateJooq" }
build_java_home = nil, -- JDK for that build (default: the project's; false = inherit)
notify = true, -- toast run start / result
open_summary = true, -- open the neotest summary on a run
autoclose_summary = true, -- close it after an all-green focused run
console_launcher_path = nil, -- path to the JUnit console-standalone jar
debug = nil, -- test debugger: nil = jc-native, "external" = nvim-jdtls/nvim-java
},
})class_prompt = "wizard"
Swaps the one-line DSL prompt for a step-by-step vim.ui.select/vim.ui.input
flow (template → module → package → name → extends/implements/fields/flags).
Each step is a short clean list, which avoids the cmdline-completion truncation
of very long package paths. The mapping <p>N always runs the wizard,
regardless of this option.
class_type_exclude
Adds package prefixes to hide from the extends/implements/field-type
completion. The prompt resolves types from jdtls' workspace symbols, which
include non-importable ones; nested classes, shaded jars, internal/impl
packages and a built-in list of known JDK/library internals (sun.*,
com.sun.*, jdk.internal, jackson introspect/cfg/…) are dropped
automatically. The LSP gives no visibility, so package-private classes in
ordinary packages can still slip through — add their prefixes here, e.g.
{ "com.example.somelib.internalish" }.
update_config_on_new_file
A java file created in-editor isn't on jdtls' build path until the project
configuration is refreshed, so go-to-definition returns nothing on it (while
find-references still works off the search index). With this on (default), jc
detects such files and fires :JCutilUpdateConfig on their first write. Set it
to false to refresh manually.
Legacy globals
g:jc_default_mappings, g:jc_autoformat_on_save, g:jc_debug_backend and
g:jc_basedir still work as a fallback when the corresponding option isn't
passed to setup.
:checkhealth jc verifies the setup; :help jc has the full reference.
Imports & code generation
| Command | Action |
|---|---|
JCimportsOrganizeSmart |
organize imports, auto-picking remembered classes |
JCimportsOrganize |
organize imports, choosing from the candidate list (and forgetting the remembered pick, which is how you revise it) |
JCimportsReplace |
replace the import of the type under the cursor (pick among same-named, e.g. lombok.Value vs spring's) |
JCimportsRemoveUnused |
remove all unused imports (no reordering) |
JCimportsAddMissing |
add all missing imports (no reordering; smart-picks ambiguous names) |
JCimportsOrganizeNoSort |
add missing + remove unused, without reordering the rest |
JCimportsStyle |
pick an IDE import-sort preset (Eclipse / IntelliJ IDEA / VS Code / Google), remembered per project; its last entry toggles wildcards off for the project |
JCimportsExpandStars |
expand the pkg.* imports of the buffer into single-type ones |
A pick for an ambiguous name is remembered per project (a file next to the
jdtls workspace), and the smart commands above apply it without asking again.
Organizing the imports of a newly created class counts as a smart run, so a
javax vs jakarta choice is made once, not on every new class. One class is
remembered per simple name: picking another one, in the picker or with
JCimportsReplace, replaces it rather than piling up next to it.
A preset also decides when a package collapses into pkg.*: IntelliJ IDEA's
does it at five imports from one package (three for static ones), Eclipse's
never. JCimportsExpandStars organizes the buffer once with those thresholds
out of reach, so jdtls rewrites every pkg.* into the types the file actually
uses - the import list is rebuilt from the source, not patched. To keep them
expanded for good, turn on no wildcards in the JCimportsStyle picker: the
group order of the chosen preset stays, only the collapsing goes.
| JCgenerateToString | generate toString() with field selection |
| JCgenerateHashCodeAndEquals | generate hashCode() and equals() |
| JCgenerateAccessors | choose fields for accessor generation |
| JCgenerateAccessorGetter / …Setter / …SetterGetter | getter / setter / both for a field |
| JCgenerateConstructor | choose fields for a constructor |
| JCgenerateConstructorDefault | no-arg constructor |
| JCgenerateAbstractMethods | add unimplemented methods |
Class creation & navigation
| Command | Action |
|---|---|
JCgenerateClass |
class creation prompt (DSL or wizard per class_prompt) |
JCgenerateClassWizard |
class creation, always the step-by-step wizard |
JCgenerateClassFromCursor |
create the class named under the cursor (pick package/module, then the DSL) |
JCgotoTest |
jump to the test class (or back), creating it if missing |
JCgotoFqn |
open the java file for the FQN under the cursor |
Refactor
| Command | Action |
|---|---|
JCrefactorExtractVar |
extract variable (all occurrences) |
JCrefactorExtractMethod |
extract method (visual range) |
JCrefactorStaticImport |
convert the call at the cursor to a static import |
JCrefactorStaticImportEnum |
static-import every constant of the enum |
JCrefactorFlipArgs |
swap receiver and argument of the call at the cursor (a.equals(b) → b.equals(a)) |
JCrefactorMove |
move the current class to another package/source root, updating references |
JCannotateMethod / JCannotateClass |
add an annotation to the enclosing method / class (search jdtls by name, import remembered); type Service spring to narrow same-named types by package |
Test runner
| Command | Action |
|---|---|
JCtestRun |
run the test at the cursor |
JCtestFile |
run every test in the current file |
JCtestDebug |
debug the test at the cursor (delegates to nvim-jdtls / nvim-java) |
JCtestSuite |
run every test under the project root |
JCtestPick |
pick a test class from the whole project and run it |
JCtestLast |
re-run the last test position |
JCtestStop |
stop the running test |
JCtestSummary / JCtestOutput |
toggle summary / open the test's output |
JCtestPrecompile |
toggle build-tool precompile before a run |
JCtestInstall |
download the JUnit console launcher matching the project's junit (optional version argument) |
Build runner
| Command | Action |
|---|---|
JCbuildRun [args] |
run gradle/maven with args (or prompt, defaulting to the last run) |
JCbuildTask |
pick a module then a task/goal |
JCbuildLast |
repeat the last build task |
Debug & utilities
| Command | Action |
|---|---|
JCdebugAttach / JCdebugLaunch |
attach / launch the debugger |
JCdapAttach / JCvimspectorAttach |
attach with a specific backend |
JCdebugWithConfig |
start with a chosen vimspector configuration |
JCtoggleAutoformat |
toggle format-on-save |
JCutilUpdateConfig |
re-read the project configuration (pom/gradle) |
JCutilWipeWorkspace |
delete the jdtls workspace and restart (works even if jdtls failed to start) |
JCutilFixJdk |
point jdtls' gradle sync at the JDK the build targets (reported by :checkhealth jc) |
JCutilJshell |
java shell with the project classpath |
JCutilBytecode |
bytecode of the current class (javap) |
JCutilJol |
object layout (jol) |
Installed on jdtls attach when default_mappings is enabled. <p> is
keys_prefix (default <leader>j).
| Mode | Keys | Action |
|---|---|---|
| n | <p>i / <p>I |
organize imports — smart (sorted) / no reordering |
| n | <p>ro |
pick an IDE import-sort preset, remembered per project |
| i | <C-j>i |
organize imports |
| n | <p>ts |
toString() |
| n | <p>eq |
hashCode() and equals() |
| n | <p>A |
accessors (field selection) |
| n | <p>s / <p>g / <leader>ja |
setter / getter / both |
| i | <C-j>s / <C-j>g / <C-j>a |
accessor generation |
| n | <p>c / <p>cc |
constructor (fields) / default constructor |
| n | <p>m, i <C-j>m |
abstract methods |
| n | <p>n / <p>N |
new class — prompt / wizard |
| n | <p>nc |
create the class named under the cursor (missing from the project) |
| n | <p>t |
jump to the test class (or back) |
| n | gf |
go to file, or the java file of the FQN under the cursor |
| n | <p>Tr / <p>Tf / <p>Ta / <p>Tl |
run test at cursor / file / all / last |
| n | <p>Td |
debug the test at the cursor (via nvim-jdtls / nvim-java) |
| n | <p>Tp |
pick a test class from the project and run it |
| n | <p>Ts / <p>To |
toggle test summary / open test output |
| n | <p>b / <p>B |
run gradle/maven (prompt) / pick a task |
| n | <p>da / <p>dl |
debug attach / launch |
| v | <p>re / <p>rm |
extract variable / method (selection) |
| n | <p>re |
extract variable, all occurrences (at cursor) |
| n | <p>rs / <p>rS |
static import — call / every enum constant |
| n | <p>rp |
replace the import of the type under the cursor |
| n | <p>ru / <p>ri |
remove unused / add missing imports (no reordering) |
| n | <p>rw |
expand pkg.* imports into single-type ones |
| n | <p>rf |
flip receiver and argument of the call (a.equals(b) → b.equals(a)) |
| n | <p>rM |
move the current class to another package (references updated) |
| n | <p>am / <p>ac |
add an annotation to the enclosing method / class |
:JCgenerateClass (<p>n) opens a one-line prompt. The scheme, slot by slot:
template : [subdir] : /package.ClassName extends X implements Y permits Z (fields) :flags
└── 1 ─┘ └── 2 ─┘ └─────── 3 ──────┘ └──────── 4 ─────────┘ └── 5 ─┘ └ 6 ┘
| # | Slot | Meaning |
|---|---|---|
| 1 | template: |
(optional) a template — record, entity, service, junit5, … (see Templates) |
| 2 | [subdir]: |
(optional) a source-set or subproject (see below) |
| 3 | /package.Name |
class name and package. Leading / = absolute in the source root; without it, relative to the current file's package |
| 4 | extends/implements |
(optional) supertypes, imported automatically |
| 4b | permits |
(optional) sealed subtypes; implies the sealed modifier |
| 5 | (fields) |
(optional) type name, comma-separated, private by default. Drop the name and it is derived from the type (final RiTypeEvent → private final RiTypeEvent riTypeEvent). For enum this slot lists the constants |
| 6 | :flags |
(optional) code-gen and lombok flags (see below) |
Everything except the class name is optional — /com.app.User alone makes an
empty class.
Absolute (leading /) — the package is taken literally:
| Prompt | Creates |
|---|---|
/com.app.User(String name, int age) |
a User class with two fields |
/com.app.User(String name):constructor:toString |
…plus an all-args constructor and toString |
record:/com.app.Point(int x, int y) |
a record Point(int x, int y) |
entity:/com.app.Order(String number) |
an @Entity with an @Id id and @Column fields |
interface:/com.app.OrderRepo extends CrudRepository |
an interface extending CrudRepository |
enum:/com.app.Status(NEW, PAID, SHIPPED) |
an enum with those constants |
sealed:/com.app.Shape permits Circle, Square |
a sealed interface naming its subtypes |
service:/com.app.OrderService |
an @Service class |
/com.app.UserDto(String id, String name):lombokData |
a class annotated @Data |
service:/com.app.JobRunner(final RiTypeEvent, final DictionaryService) |
an @Service whose fields are named after their types |
[test]:/com.app.UserTest |
a class under src/test/java |
[core]:/com.app.Foo |
a class in the core module (multi-module) |
Relative (no leading /) — the package is resolved against the current
file's package. Editing com.app.service.OrderService:
| Prompt | Creates |
|---|---|
Helper |
com.app.service.Helper |
Helper(String name) |
…with a field |
util.Strings |
com.app.service.util.Strings (a sub-package) |
record:Money(long amount) |
com.app.service.Money from the record template |
Trailing :flag segments run after the class is created. Code generation
flags go through jdtls:
| Flag | Generates |
|---|---|
constructor |
an all-fields constructor |
toString |
toString() |
hashCode |
hashCode() |
equals |
equals() |
On a record these four are skipped, and so is the accessor step that otherwise
runs whenever a class is created with fields: the compiler writes all of them
for the components. Same for the accessors on an interface or @interface.
Lombok flags add the annotation (and its import, resolved by organize-imports) instead of generating code:
| Flag | Annotation | Flag | Annotation | |
|---|---|---|---|---|
lombok / lombokData |
@Data |
lombokNoArgs |
@NoArgsConstructor |
|
lombokValue |
@Value |
lombokAllArgs |
@AllArgsConstructor |
|
lombokBuilder |
@Builder |
lombokRequiredArgs |
@RequiredArgsConstructor |
|
lombokGetter |
@Getter |
lombokToString |
@ToString |
|
lombokSetter |
@Setter |
lombokEqualsHashCode |
@EqualsAndHashCode |
|
lombokSlf4j |
@Slf4j |
Flags combine: /com.app.User(String name, int age):lombokData:lombokBuilder
→ a @Data @Builder class.
- a source-set name places the class in the current module's
src/<name>/java—[test]mirrors the package intosrc/test/java; - a subproject name (multi-module) targets that module directly —
[core]or[core/test]for its test sources.
When an absolute package you pick already lives in another module (completion offers packages from every subproject), jc asks which module to create the class in; a brand-new package goes to the current module.
<Tab> completes each slot in turn:
- the template and, after
/, existing packages across the whole project (every subproject) — without a leading/, the sub-packages of the current file's package instead; either way you can still type a new package by hand; [subdir]after a template — source-sets and module names;- the flags once the class path is given;
- after
extends/implements— class/interface names resolved live from jdtls.
<p>N (or class_prompt = "wizard") runs the same thing as a step-by-step
vim.ui flow instead of the one-liner.
With the cursor on a class name the code refers to but that doesn't exist yet,
<p>nc (:JCgenerateClassFromCursor) picks up that name, asks for a package
(every existing project package, the current one, or a new one — and the module
on a multi-module project), then drops you in the DSL prompt pre-filled with
[module]:/pkg.Name so you can still add extends, fields or flags before
creating it.
Built-in: class, interface, enum, record, annotation, exception,
main, singleton, serializable, sealed, sealed_class, servlet,
junit, junit5, entity, service, component, repository, controller
and the android_* family.
The entity template carries @Entity and an @Id id, and annotates each
prompt field with @Column(name = "<snake_case>"). Imports are left to
organize-imports (run automatically after creation), so it works whether your
project uses jakarta.* or javax.*.
The controller template maps the path its name implies
(UserController → @RequestMapping("/user")), servlet maps MyFileServlet
to /my-file, exception declares the four conventional constructors, and
junit/junit5 scaffold a @Test. Fields given to interface or annotation
become members (String name();), not fields.
sealed and sealed_class produce a sealed interface or class; name the
subtypes with permits, and the wizard asks for them as an extra step. A
permits clause on a plain interface/class adds the sealed modifier by
itself, since the two only make sense together.
The serializable template implements Serializable and declares a freshly
generated serialVersionUID above the fields. An implements given in the DSL
is merged with the template's own, so
serializable:/com.app.Money implements Comparable<Money> still keeps
Serializable.
The repository template is a spring-data interface over the entity its
name implies — repository:/com.app.UserRepository gives
public interface UserRepository extends JpaRepository<User, Long> (a trailing
Repository/Repo is stripped). There is no @Repository: spring-data builds
the bean from the interface. Write your own supertype to override it, e.g.
repository:/com.app.UserRepository extends CrudRepository<User, UUID>.
Point templates_dir at a folder of <name>.lua files. Each returns either
a declarative spec table (recommended — describe only the essence, the engine
builds the rest) or a function(opts) -> string for full control.
A Lombok DTO is just imports + an annotation, no skeleton to repeat:
-- ~/.config/nvim/jc-templates/dto.lua
return {
imports = { "lombok.Data" },
annotations = { "@Data" },
}dto:/com.app.User(String name, int age) then produces a @Data class with the
package, declaration and fields filled in.
Spec fields (all optional): kind (class/interface/enum/annotation/
record), modifiers, extends, implements, imports, annotations,
body, pre_fields (members before the prompt fields), field_annotation
(function(field) -> string). imports/annotations/body may each be a
string, a list or a function(opts). User input for extends/implements
overrides the spec defaults. opts: name, package, fields
({ mod, type, name }), extends, implements.
jc.nvim ships an optional set of Java field/modifier and NetBeans-style
snippets (snippets/java.json, VS Code format). jc doesn't run a snippet engine
— point your own at the folder. The prefix scheme: p/P = private/public,
s = static, f = final; a lowercase type initial is a primitive (psfl →
private static final long), an uppercase one a wrapper (psfL → … Long).
Plus fori, forl, ife, dowhile, whileit, inst, pst, soutv,
runn, lazy.
Wiring it into your snippet engine
Point the loader at the plugin's snippets/ directory (adjust the path to your
plugin manager; the lazy.nvim location is shown):
-- LuaSnip
require("luasnip.loaders.from_vscode").lazy_load({
paths = { vim.fn.stdpath("data") .. "/lazy/jc.nvim/snippets" },
})
-- nvim-cmp + vsnip, blink.cmp, or native vim.snippet users: load the same
-- VS Code snippet folder however your engine consumes `package.json` bundles.jc.nvim ships a neotest adapter.
neotest is an optional dependency — without it the plugin works as before
and the JCtest* commands warn instead of erroring. Unlike the gradle/maven
adapters, this one resolves the test classpath straight from jdtls and runs the
JUnit Platform Console Standalone
launcher, so there's no build-tool daemon to wait for and gradle/maven/plain
layouts all work the same way. Wire it as in
Installation.
The launcher jar is looked up in ~/.m2; if missing, run :JCtestInstall once
(downloads org.junit.platform:junit-platform-console-standalone via maven) or
set test.console_launcher_path.
The jar bundles its own JUnit engines and they take precedence over the
project's, so jc picks the version matching the junit on the test classpath:
junit 5.11.3 needs the 1.11.3 jar, junit 6.0.3 needs 6.0.3 (junit 6 merged the
two numbering schemes). Running a JUnit 6 project on a 1.x jar fails inside the
framework - typically NoSuchMethodError from SpringExtension. :JCtestInstall
fetches the version the current project needs; pass one explicitly with
:JCtestInstall 6.0.3. A test.console_launcher_path always wins.
The download goes through maven when it is on PATH, and falls back to a direct
fetch from Maven Central (curl or wget) when maven cannot get the jar - a
corporate mirror in settings.xml intercepts every maven request and such
mirrors often lag behind on new artifacts. Progress is echoed in the cmdline.
Run tests with :JCtestRun (cursor), :JCtestFile, :JCtestSuite,
:JCtestPick, :JCtestLast, or the <p>T* mappings; neotest paints the gutter
green/red and a failed test's diagnostic points at the failing line. Runs open
the summary panel and, via the optional jc consumer, auto-close it after an
all-green focused run (cursor/file/class) — runs with failures stay open.
JCtestRun/JCtestFile also work from a production class: they run its paired
<Class>Test (the same counterpart JCgotoTest uses) when it exists. Without
one, the test classes of the class's own package are offered in a picker - a
class is often covered by a test named after the scenario rather than after the
class. With no tests in the package either, jc says so and starts no run.
The adapter toasts running… at the start and N passed, M failed, K skipped
at the end. Knobs: test.notify, test.open_summary, test.autoclose_summary
(false, or a delay in ms).
Before a focused run jc reloads the target file and has neotest reparse it, so
a test written to disk from outside the editor (an agent, a git checkout) is
picked up without saving the buffer by hand — neotest itself rediscovers a file
only on BufAdd/BufWritePost. A buffer with unsaved changes is left alone,
and the reparse needs the jc consumer wired in (that is where the neotest
client comes from). :JCtestSuite is not refreshed: it runs off the tree as it
stands, so a brand-new test file joins it only once neotest rescans the
directory.
Debugging tests — :JCtestDebug (<p>Td) debugs the test at the cursor.
Set your breakpoints first. Needs nvim-dap
and the java-debug bundle in jdtls
(nvim-java bundles it).
By default jc runs its own debugger: it launches the JUnit Platform Console Launcher under a JDWP agent (same classpath and JDK as a normal run) and attaches nvim-dap to it, then reports pass/fail from the XML. Because the console launcher is standalone (its own junit-platform), this works on any project regardless of junit version — including ones where the delegated runner below silently finds 0 tests.
Set test.debug = "external" to delegate to
nvim-jdtls
(jdtls.dap.test_nearest_method) or
nvim-java
(test.debug_current_method) instead — you get their report UI, but their
eclipse test runner bundles its own junit (~5.11) and can discover 0 tests on
a project whose junit differs from the bundle's. "external" falls back to
jc-native when neither plugin is installed.
Classpath, JDK selection and freshness
The classpath is built from jdtls and augmented for correctness:
-
test + runtime scopes are unioned — jdtls'
testscope omitsruntimeOnlydependencies ByteBuddy/Mockito need at run time (otherwise "green from the CLI butNoClassDefFoundErrorhere"). -
By default (
precompile = false) jc forces a jdtls compile (java/buildWorkspace), uses jdtls'binoutput first and the gradle/mavenbuild/-target/dirs as a fallback. Fast, and fine when jdtls compiles the whole project. -
Some projects have classes jdtls won't put in
bin(e.g. certain spring-data repositories) — the run then fails withClassNotFoundExceptionfor a class that exists in the build output. Settest.precompile = true(or toggle with:JCtestPrecompile): jc runsgradle :<module>:testClasses/mvn test-compilefirst and uses the completebuild/-target/output. The compile is async (editor stays responsive, progress in the cmdline), cached per module for the run, and on failure the javac/maven errors go to the quickfix list instead of running the tests. -
A precompile can also fail on a task that has nothing to do with the tests - a code generator that wants a live database, say. When its output is already on disk, keep the task out of the precompile with
test.precompile_exclude = { "generateJooq" }(gradle-x); the failure message names the task to put there. The option is global, the task is not: in a project that has no such task jc drops it and builds again, so one list can name the generators of every project you work on. -
That build runs on the project's JDK (the same one the tests launch with), not on whatever
JAVA_HOMEnvim inherited — an older gradle on a JDK 16+ otherwise dies withIllegalAccessError: ... jdk.compiler does not export com.sun.tools.javac.*. Override withtest.build_java_home = "/path/to/jdk", or set it tofalseto keep nvim's environment. -
The run JVM is the configured
java.configuration.runtimesentry matching the highest bytecode version among the module's classes (a 17-compiled test over an 11-target main still runs on 17, as gradle does), falling back toresolveJavaExecutablethen PATHjava. -
jdtls compiles with the Eclipse compiler, whose equivalent of javac
-parametersis off by default, so classes inbincarry no method parameter names. Spring Data reads those names by reflection, so a repository with named query parameters that works from the CLI fails here with "For queries with named parameters you need to provide names for method parameters". Fix it in the project with@Param("...")(compiler-independent), or turn the flag on for jdtls -org.eclipse.jdt.core.compiler.codegen.methodParameters=generatein the module's.settings/org.eclipse.jdt.core.prefs, or in the filejava.settings.urlpoints at.precompile = truesidesteps it too, since the bytecode then comes from javac.
:checkhealth jc ends with the project's java levels: what the build declares,
what eclipse compliance jdtls settled on, and what level the build output
actually is. When they disagree it says how that bites and what to do:
- jdtls syncs gradle on another JDK than the build targets. Gradle keeps one
daemon per JDK, so the editor's sync and jc's precompile each hold their own
(~1GB apiece).
:JCutilFixJdkrewritesjava.homein the project's.settings/org.eclipse.buildship.core.prefsafter a confirmation; re-sync with:JCutilUpdateConfigafterwards. - Eclipse compliance differs from the build's level. The LSP then checks the
code at the wrong language level. The usual cause is a build that sets the
level where the gradle Tooling API cannot see it -
compileJava { sourceCompatibility = 11 }instead ofjava { sourceCompatibility = 11 }- and jc says so when it recognises that shape. It never rewritesbuild.gradleitself. binandbuild/classessit at different levels, which is what the test runner has to launch over.- jdtls compiles without method parameter names, which breaks spring-data repositories with named query parameters (see the paragraph above).
If jdtls keeps dropping classes from bin, a :JCutilWipeWorkspace + restart
(clean re-import) often makes bin complete again, keeping you on the fast
precompile = false path.
On a multi-module project :JCtestSuite is best-effort: neotest reruns
update_running over the shared tree per sub-run, which can reset an
already-failed class back to running in the summary. Iterate with the focused
:JCtestRun/:JCtestFile, which run as a single neotest run and report
reliably.
Run gradle/maven tasks from the editor, in a dedicated split (q closes it);
compile errors are parsed into the quickfix list.
:JCbuildRun [args]— run with the given args, or prompt (defaulting to the last run, remembered per project). Wide pty so longfile:line:errors aren't wrapped.:JCbuildTask— pick a module (or the whole project), then a task: gradle tasks fromgradlew tasks, or for maven the lifecycle phases, pom profiles/plugin goals and a plugin drill-down (mvn help:describelists every goal of the chosen plugin). The module scopes the run (gradle:module:task, maven-pl module -am).:JCbuildLast— repeat the last task.
Commands run from the reactor root (outermost contiguous pom /
settings.gradle), so multi-module builds resolve paths and the reactor
correctly. A checkout nested inside the project - a git worktree under
.claude/worktrees, say - keeps its own root: the climb stops where a
settings.gradle does not include the directory below it.
JCgotoFqn (and the overridden gf) opens the java source for a
fully-qualified name under the cursor — for jumping out of a terminal, a neotest
output window or a pasted stack trace into the code. It understands:
- a bare FQN
com.foo.Bar(andcom.foo.Bar$Inner→ the outer file); - an FQN with a member
com.foo.Bar.method→ theBarfile; - a line suffix
com.foo.Bar:42; - a stack frame
at com.foo.Bar.method(Bar.java:25)→Bar, line 25 (rejoined even when a narrow terminal wrapped it across two lines).
The file opens in the last window that showed a java buffer (so you can trigger it from a terminal split and land back in your editing window), or a new tab when there is none. The FQN is resolved through jdtls' symbol index (works from any buffer) with a source-tree fallback.
When default_mappings is on, gf is overridden globally and falls back to the
builtin gf when the token isn't an FQN (e.g. a real path). Disable with
setup{ map_gf = false }.
JCdebugAttach / JCdebugLaunch route to a backend:
- the
debug_backendoption /g:jc_debug_backendif set ("dap"or"vimspector"); - auto: nvim-dap installed and vimspector absent → dap;
- fallback: vimspector.
Attach asks for host and port, remembered per project. The adapter port is
resolved from jdtls via vscode.java.startDebugSession, which needs the
java-debug bundle.
| Feature | nvim-jdtls | jc.nvim |
|---|---|---|
| Code generation | via code actions | dedicated commands/mappings with field selection |
| Organize imports | code action | smart mode remembering preferred classes per project |
| Class creation | — | DSL prompt / wizard with templates |
| Test runner | — | neotest adapter, classpath from jdtls |
| Build runner | — | gradle/maven task picker → quickfix |
| Debug attach | manual dap config | JCdebugAttach with per-project host/port memory |
| javap/jshell/jol | yes | classpath-aware, built-in |
- Run
:checkhealth jc— it verifies the Neovim version, the attached jdtls client, organize-imports and java-debug availability, the debug backends, classpath resolution and neotest/launcher for the test runner, the jol jar, the treesitter java parser and the data dir. - Go-to-definition returns nothing on a just-created file — it isn't on the
build path yet;
update_config_on_new_filehandles this on first write, or run:JCutilUpdateConfig. - jdtls state looks corrupted / won't start —
:JCutilWipeWorkspacedeletes the eclipse index and restarts (works even with no client attached). - Tests fail with
ClassNotFoundExceptionfor classes that exist — enabletest.precompile(see Test runner).





