Available Hire Me
← All Writing Java

A Terminal-First Java and Maven Setup — Neovim, jdtls and the Build Loop

Moving a 69-module Spring Boot reactor from IntelliJ to Neovim: jdtls capped at 995 MB instead of 1893, DAP attach debugged, and a build loop you own.

I moved my daily loop on a 69-module Spring Boot reactor out of IntelliJ and into the terminal: navigate, read, edit, build, test, debug, commit. IntelliJ still earns its place for two refactors and its Spring tool windows, and I open it perhaps once a week for half an hour.

This is the setup, the measurements behind it, and the traps that cost real time. It ran on Linux Mint, 8 cores, 7.6 GB of RAM — a 3.9 MB reactor pom.xml on a laptop with no memory to spare, which is the constraint that made any of this worth doing.

What IntelliJ is actually costing

The reason IntelliJ eats a machine is not Java indexing. It is the whole product: a JVM with a UI toolkit, a plugin runtime, a duplicated compiler, a duplicated build model, and an index that grows with every dependency.

The terminal stack is not free either, and my estimate of that was wrong in an interesting direction. Measured on this reactor, jdtls consumed 1893 MB RSS uncapped — almost exactly the JVM’s default max heap of 25% of system RAM, being filled. Capping it at -Xmx1g brought it to 995 MB with identical hover and diagnostic results. The default heap was not a requirement, it was just an unfilled ceiling.

  IntelliJ IDEA Neovim + jdtls (measured)
Editor process ~1.2–2.5 GB RSS 35 MB (90 ms startup)
Language intelligence bundled, plus a duplicate build model jdtls 995 MB capped / 1893 MB uncapped, one per project
Second project open another full IDE instance no extra editor cost, one extra jdtls
Startup 20–60 s under 300 ms
Build bundled Maven, drifting from CI your mvn / mvnd — the same binary CI uses
Plugin tax every plugin you ever installed opt-in, per-plugin, no daemon

Realistic net: 1–1.5 GB freed against IDEA running the same reactor, and — more valuable — one build tool, one truth. If a gigabyte of jdtls is still too much, the honest fallbacks are -Xmx768m (slower indexing) or turning off maven.downloadSources (no source browsing). The repo already enforces Maven 4 and mvnd for the daemon loop; IDEA’s bundled Maven is a second, subtly different build.

Where the terminal actually wins is that the build loop is a script you own, not a green triangle you click. <module> -am -Pquick becomes muscle, -Dtest=FooTest#bar is one keystroke, and the MVND daemon makes a warm build 28–42% faster than IDEA’s incremental compile of a stale model.

Phase 1 — the force multipliers

These do 80% of the “terminal feels fast” work and they are additive: install, configure, and nothing depends on them.

Tool What it replaces
ripgrep 15.2 grep, and IDEA’s “Find in Path”
fd 10.5 find
zoxide 0.10 oh-my-zsh’s z plugin
git-delta 0.20 git diff you cannot read
direnv 2.38 remembering which env file belongs to which directory
hyperfine 2.0 guessing whether a build change helped

I installed prebuilt static binaries into ~/.local/opt/<tool>-<version>/ with symlinks in ~/.local/bin, needing no root at all. Undo is rm the directory and its symlink. Two wrinkles: zoxide 0.10’s musl tarball ships docs only, so the binary has to come out of the .deb; and zoxide import z reads $_Z_DATA rather than taking a path argument, because 0.10 moved to subcommands.

The config that matters is three files. In ~/.zshrc, guarded so a missing binary can never break startup:

export RIPGREP_CONFIG_PATH="$HOME/.ripgreprc"
# zoxide replaces oh-my-zsh's `z` plugin; --cmd z keeps your `j → z` alias working.
if command -v zoxide >/dev/null 2>&1; then
  eval "$(zoxide init zsh)"
fi
if command -v direnv >/dev/null 2>&1; then
  eval "$(direnv hook zsh)"
fi

~/.ripgreprc — the point of which is that target/ is never searched again:

--smart-case
--hidden
--glob=!.git/*
--glob=!target/**
--glob=!node_modules/**
--glob=!*.class

~/.gitconfig:

[core]
    pager = delta
[interactive]
    diffFilter = delta --color-only
[delta]
    navigate = true
    line-numbers = true
    side-by-side = false

One conflict to know about: oh-my-zsh’s z plugin and zoxide both want z. Remove z from plugins=(...). Use zoxide init zsh --cmd cd instead if you would rather frecency-hijack cd and leave z and j free. Existing frecency imports with zoxide import --from=z ~/.z.

Verified after the change: rg --files returns zero files under target/, fd -e java agrees with it at 2,820 files, delta emits ANSI through a real TTY, and shell startup is unchanged (652 ms against a 650 ms baseline — within noise, and it was already slow; the eager nvm load is the culprit).

Phase 2 — Neovim, and three things that bite on first run

Neovim 0.12.6 from the official tarball into ~/.local/opt/nvim-0.12.6. Do not use the distribution package: Mint offers 0.9.5, and the native LSP API and modern plugins assume at least 0.10, most of them 0.11.

mkdir -p ~/.local/opt ~/.local/bin
curl -fL -o /tmp/nvim.tar.gz \
  https://github.com/neovim/neovim/releases/latest/download/nvim-linux-x86_64.tar.gz
tar -xzf /tmp/nvim.tar.gz -C ~/.local/opt
ln -sfn ~/.local/opt/nvim-linux-x86_64 ~/.local/opt/nvim
ln -sfn ~/.local/opt/nvim/bin/nvim ~/.local/bin/nvim
nvim --version | head -2

The base config is LazyVim, not a hand-rolled init.lua. That is a deliberate trade: the goal was to be productive on a Java reactor in week one and learn the editor over four weeks, not to spend weeks one and two debugging a config from scratch. LazyVim ships the LSP, DAP, test and treesitter plumbing and is documented well enough to read.

git clone https://github.com/LazyVim/starter ~/.config/nvim
rm -rf ~/.config/nvim/.git && git init ~/.config/nvim   # version your config from day one
nvim   # let it finish installing, then :q

In ~/.config/nvim/lua/config/lazy.lua, the extras that matter:

spec = {
  { "LazyVim/LazyVim", import = "lazyvim.plugins" },
  { import = "lazyvim.plugins.extras.lang.java" },   -- nvim-jdtls + java-debug-adapter + java-test
  { import = "lazyvim.plugins.extras.dap.core" },    -- debugger
  { import = "lazyvim.plugins.extras.test.core" },
  { import = "lazyvim.plugins.extras.editor.fzf" },  -- fzf-lua, lighter and faster than telescope
  { import = "lazyvim.plugins.extras.coding.yanky" },
  { import = "lazyvim.plugins.extras.util.mini-hipatterns" },
  { import = "plugins" },
},

Three things the stock starter gets wrong or that bite immediately:

  1. The starter disables the tutor runtime plugin in disabled_plugins, so :Tutor — week one, day one of the plan below — does not exist. Re-enable it.
  2. Deleting lua/plugins/example.lua breaks startup with No specs found for module "plugins" if lua/plugins/ is then empty while { import = "plugins" } is in the spec. A lua/plugins/java.lua fills that hole.
  3. nvim --headless "+Lazy! sync" +qa is safe for plugins but not for Mason packages — Mason’s installs are async and +qa aborts them mid-download. See the Mason trap in the risk register.

Phase 3 — jdtls and Lombok, and the setting that mattered

The pleasant surprise: the Lombok javaagent was already handled. LazyVim’s lang.java extra builds jdtls --jvm-arg=-javaagent:$MASON/share/jdtls/lombok.jar itself, and Mason’s jdtls package ships that jar. My original plan — a hand-written ftplugin/java.lua pointing -javaagent at the project’s own Lombok jar — was unnecessary, and adding it would spawn a second server.

I verified it end-to-end by hovering a getter that only exists because the class is annotated @Data:

attach             : OK          root_dir: ~/Projects/ninja-2.0
hover on generated : int SomeModel.getSizeKb()
diagnostics        : 0           suspicious (compliance/undefined/unresolved): 0

That single hover proves four things at once: jdtls attached, the Maven reactor imported (the log shows Updating <module> configuration for every module), Java 27 parsed, and Lombok’s generated method resolved with the correct return type. jdtls’s own workspace log has zero ERROR or SEVERE entries and no compliance warnings. The Java 27 risk I had budgeted for simply did not exist.

The tuning lives in ~/.config/nvim/lua/plugins/java.lua, kept small on purpose:

return {
  {
    "mfussenegger/nvim-jdtls",
    ft = "java",
    opts = function(_, opts)
      -- measured: 1893 MB uncapped (== JVM default 25% of RAM); 995 MB at 1g
      if opts.cmd then table.insert(opts.cmd, "--jvm-arg=-Xmx1g") end
      opts.dap_main = false                                    -- main-class scan = perf killer
      opts.settings = vim.tbl_deep_extend("force", opts.settings or {}, {
        java = {
          inlayHints = { parameterNames = { enabled = "literals" } }, -- LazyVim uses "all" (redraw churn)
          format = { enabled = false },                               -- fmt-maven-plugin owns style
          maven = { downloadSources = true },
          configuration = { updateBuildConfiguration = "automatic" },
        },
      })
      return opts
    end,
  },
}

Two of those lines are load-bearing rather than taste. java.format.enabled = false because the build runs fmt-maven-plugin and two formatters will disagree on every save. dap_main = false because scanning for main classes is a measurable cost on a reactor this size.

The Java-specific keys the extra gives you — these are the ones you lose from IDEA, and they are not in LazyVim’s default documentation: <leader>cxm extract method (visual mode), <leader>cxv extract variable, <leader>cxc extract constant, <leader>co organise imports, <leader>cgs go to super implementation, <leader>tt run all tests, <leader>tr run nearest test, <leader>tT pick a test.

If Lombok ever breaks — the build bumps Lombok past what Mason’s bundled jar supports — override opts.cmd to add --jvm-arg=-javaagent:<path to the project's own lombok jar from ~/.m2>. Diagnose it the same way it was verified: hover any generated getter. “Undefined method” means the agent did not load.

Phase 4 — debugging, and the projectName trap

I proved the debugger on a purpose-built throwaway Maven project with a loop, so none of it touched a production app:

jdtls attached         : OK        java adapter registered: OK
breakpoint set         : Hello.java:5
stack frames           : 1 (top: Hello.main(String[]))
events                 : initialized, setting-breakpoints, stopped#1(breakpoint), stopped#2(breakpoint)
loop var i @ stop 1    : 0         loop var i @ stop 2 : 1
RESULT                 : DAP WORKS (breakpoint, continue, live variable reads)

So: attach, breakpoint hit, real stack frame, continue, second hit, and a variable read that changed from 0 to 1. That is the whole drill minus the UI.

The gotcha that cost most of an hour was projectName. LazyVim’s built-in attach entry at :5005 hits breakpoints perfectly well, and then:

  • evaluate — watch expressions, conditional breakpoints — fails with Cannot evaluate, please specify projectName in launch.json
  • scopes returns empty, so the Variables pane looks blank and the debugger looks broken

java-debug resolves both through jdtls’s project model, which gives you two traps in one. The value must be the Maven artifactId (ninja-app-server), not a path. And java.project.getAll returns file:/... URIs; feeding one back fails with Path for project must have only one segment but has 5.

The fix is a first-in-list attach entry:

{ type = "java", request = "attach", name = "Attach ninja-app-server :5005",
  hostName = "127.0.0.1", port = 5005, projectName = "ninja-app-server" }

Both entries coexist, and the difference is visible in the picker: 1. Attach ninja-app-server :5005 projectName=ninja-app-server against 2. Debug (Attach) - Remote projectName=nil. Start the app with a shell helper and you are done:

ninja-debug   # mvn -pl ninja-app/ninja-app-server spring-boot:run with -agentlib:jdwp=...,suspend=n,address=*:5005

Then <leader>dc to continue and pick the entry, <leader>db to toggle a breakpoint, <leader>du for the UI.

Two JDWP facts worth knowing before you lose an afternoon. A JDWP agent accepts one debugger connection per JVM — after you detach, the port is gone, so restart the app to re-attach. (suspend=y only matters if you want to catch startup.) And if the debuggee’s sources are not in a project jdtls knows, stops report Source missing, cannot jump to frame: the breakpoint still works, you just cannot see the code.

JVM forensics without a debugger

jfr is the one that works everywhere and it is worth having in your hands:

java -XX:StartFlightRecording=duration=8s,filename=rec.jfr,settings=profile -cp . Hello
jfr summary rec.jfr                                      # chunk length, event counts by type
jfr print --events jdk.ThreadSleep rec.jfr
#   jdk.ThreadSleep { duration = 113 ms; eventThread = "main";
#                     stackTrace = [ Hello.main(String[]) line: 6 ] }

I did not get to verify jps, jcmd and jstack — they use the JVM attach mechanism, which needs /proc/<pid>/root, and the environment I did this work in blocks it. Ten seconds in your own terminal settles it, and on any normal box all four work:

java -cp ~/.cache/dap-test Hello &   # or anything long-running
PID=$!; jps -lvm | grep Hello         # lists it
jstack $PID | head -20                # thread dump: main, GC and JIT threads
jcmd $PID GC.heap_info                # heap used / committed / max
jcmd $PID Thread.print | grep -c 'java.lang.Thread.State'   # thread count by state
jcmd $PID VM.native_memory summary    # only with -XX:NativeMemoryTracking=summary

jfr record --duration=60s, the attach-based form, uses the same mechanism. Keep the throwaway project around: it is a safe place to practise breakpoints, conditional breakpoints and logpoints without starting the real app.

The build loop is the real win

This is the part that replaces IDEA’s toolbar, and it is the part no amount of modal-editing skill substitutes for.

# strict gate (CI's proof) — fresh JVM, nothing carried over
mvn -T 1C clean verify -DskipFmt=true -DskipPomSort=true

# fast inner loop (warm MVND daemon; 28–42% faster on a small graph)
scripts/main/mvnd-inner-loop.sh -pl ninja-gilts/ninja-gilts-core -am -Pquick verify
scripts/main/mvnd-inner-loop.sh -pl ninja-core/ninja-core-domain -Pquick test
scripts/main/mvnd-inner-loop.sh -pl ninja-core/ninja-core-domain -Pquick -Dtest=SomeTest#oneMethod test

# compile-only sanity while typing
mvn compile -pl <module> -am -q

# run the app
mvn spring-boot:run -pl ninja-app/ninja-app-server

Never run spring-boot:run or exec:java through the daemon. They call System.setProperty in the Maven JVM and would mutate the long-lived daemon.

-DskipFmt=true -DskipPomSort=true is not optional. fmt-maven-plugin binds to process-sources, so even a plain mvn compile reformats your working tree. On a branch with uncommitted work that is a genuinely nasty surprise. Drop the flags only when you want formatting applied.

Investigation commands worth memorising, because they replace the Maven tool window:

mvn -q help:evaluate -pl <module> -Dexpression=project.artifactId -DforceStdout
mvn -q dependency:tree -pl <module> -Dincludes=com.google.guava
mvn -q help:active-profiles
mvn -q help:describe -Dplugin=spring-boot -Dgoal=run -Ddetail
mvn -X ... | rg -v DEBUG        # when a build lies to you

Four reactor facts to keep in your head. There is a Maven 4 floor — Maven 3 cannot build it. ${revision} is one reactor version, with per-module bumps through a documented versioning process. -T 1C parallelises modules. And surefire and failsafe fork one VM per module, so forkCount=1C would multiply — which is why MAVEN_OPTS=-Xmx768m is safe but the per-module test JVMs are the real memory consumer. Build cache is off by default.

Memory discipline on 7.6 GB: cap what you run, keep one jdtls alive, close extra projects, run tests one module at a time, prefer -Pquick. Measure honestly rather than arguing with a task manager:

ps -o rss=,cmd= -C java | sort -rn | head
ps -o rss= -p $(pgrep -f jdtls) | awk '{printf "jdtls %.0f MB\n", $1/1024}'

Search and navigation

rg 'class NinjaOrder' -g '*.java'                  # find the definition
rg -n 'TODO|FIXME' ninja-engine/                   # scoped
rg -w 'matchPrice' -t java -A3 -B3                 # word, type, context
rg -U 'synchronized\s*\([^)]+\)\s*\{' -t java      # multiline
rg --files | fzf                                   # every file in the reactor, instantly
fd -e java -x wc -l {} + | sort -rn | head         # biggest classes
git grep -n 'price' $(git rev-list -n1 origin/main) # history-aware search

Inside Neovim, all under <leader> (space):

Keys What Replaces in IDEA
<leader>ff find file by name (fzf) Ctrl+Shift+N
<leader>fg live grep (rg) Ctrl+Shift+F
<leader>fb buffers Ctrl+E
<leader>fr recent files Ctrl+E (recent)
<leader>ss workspace symbols (jdtls) Ctrl+Alt+Shift+N
gd / gD definition / declaration Ctrl+B
gr / gI references / implementations Alt+F7
K hover doc / signature Ctrl+Q
[d / ]d prev/next diagnostic F2
<leader>ca code action / quick fix Alt+Enter
<leader>cr rename symbol (LSP) Shift+F6
<leader>co organise imports Ctrl+Alt+O
<leader>sr project-wide search and replace, reviewed as a diff Replace in Path
lazygit (terminal pane) full git TUI — hunk staging, rebase, amend Git tool window
Ctrl-o / Ctrl-i jumplist back/forward Ctrl+Alt+←
ma … 'a marks bookmarks

Note that <leader>gg is not mapped: LazyVim ships no lazygit extra, so lazygit runs in a terminal pane. git in the terminal is lazygit, gitsigns inside the buffer, and delta for readable diffs.

What is actually wired in the editor

The build and test keymaps resolve which module the current file belongs to by walking up to the nearest pom.xml, then up again to the topmost one for the reactor root:

Key Does
<leader>mb compile this module (-am)
<leader>mt test this module through the MVND daemon
<leader>mT test the class#method at the cursor (resolved via treesitter)
<leader>mv verify this module via the daemon
<leader>mg the CI gate: mvn -T 1C clean verify, whole reactor, deliberate
<leader>mm “which module am I in?”

Measured rather than promised, on a real domain class: gd resolves a type reference to its interface; gr returns 18 references across 6 files, which matches rg’s ground truth exactly; <leader>ss finds 6 hits for a symbol name; <leader>ff reaches any of 2,820 files; jdtls answered a triggered completion request with 32 items.

Spell check is enabled per filetype for java, markdown, gitcommit, text, rst, yaml and kotlin. Java comments carry the treesitter @spell capture, so identifiers are not checked — only the words a human reads. spelloptions=camel means priceBasis is judged as “price” plus “basis”.

Two things about that are worth passing on. Vim’s en dictionary accepts British spelling out of the box — colour, organise, initialise, defence, catalogue — so the wordlist only needs domain terms. And do not compile a dictionary from your own wordlist: a first attempt built en.utf-8.spl from 26 domain words alone, which shadowed the entire English dictionary and made every word “bad”. zg is the correct mechanism; it writes en.utf-8.add and builds en.utf-8.add.spl beside it, leaving the base dictionary intact.

Wiring an LLM CLI to a keymap is worth a mention because the mechanism is generic even if the tool is not. The useful shape is a one-shot prompt that carries the file path, the visual selection or whole buffer (capped at 400 lines), and any diagnostic on the cursor line, streaming the answer into a scratch markdown split where normal motions and / work. Point it at whichever CLI you use. I verified the plumbing with an echo stub: a 1,347-character prompt containing the file header, code fence and class body, streamed into a 55-line split.

Risk register — read this before week one

Risk Reality Mitigation
Java 27 vs JDT compliance Retired — tested and fine. Mason’s jdtls parsed java.version=27 sources with 0 diagnostics and 0 compliance errors. Keep the check in your pocket: if everything is red, look at :JdtShowLogs for the compliance level. A newer jdtls snapshot from eclipse.jdt.ls is the fallback.
Lombok Handled automatically — LazyVim’s lang.java passes -javaagent with Mason’s bundled jar. Verified by hovering a @Data getter. Only if it breaks: point -javaagent at the Lombok jar your build resolves in ~/.m2.
MapStruct Generated *Impl classes do not exist until a compile runs. Run mvn -pl <module> -am compile once after opening; jdtls picks up annotation processing.
jdtls memory 1893 MB uncapped on this reactor, 995 MB at -Xmx1g, with identical results. Workspace on disk: ~353 MB. The cap is already in lua/plugins/java.lua. One workspace per project; :JdtWipeDataAndRestart when it wedges.
Orphaned jdtls after a crashed session If Neovim dies badly, its jdtls child survives and keeps 1–2 GB. Three leaked instances held 4.7 GB at one point. pgrep -af 'eclipse.jdt.ls' then kill by pid. Never pkill -f jdtls — the pattern matches your own command line and kills your shell.
Mason’s install lie Package:is_installed() only checks that the package directory exists, and Mason creates it at install start. A wait loop on it returns instantly and the following +qa aborts the download, leaving a half-installed jdtls with no binary. Wait for is_installed() and not is_installing(). Check artifacts, not state: ls $MASON/bin/jdtls $MASON/share/jdtls/lombok.jar.
Maven 3 / mvnd 1.x The enforcer refuses Maven below 4; mvnd --version must be 2.x. Never let a tool silently pick Maven 3. Use the wrapper script as the sanctioned daemon path.
jdtls pollutes the repo with Eclipse metadata jdtls wrote 250 files into the tree: .project, .classpath, .factorypath and .settings/ per module. Timestamps prove provenance. java.import.generatesMetadataFilesAtProjectRoot = false was tested on a wiped workspace and does not work for Maven projects — all 250 came back and the log never mentions the setting. Gitignore them, with a comment explaining why. Verified: git check-ignore resolves and git add -An stages 0 of them. Harmless to leave on disk — jdtls uses them.
Formatting fights The build runs fmt-maven-plugin; jdtls formatting will disagree. java.format.enabled = false. Format through the build, or add google-java-format for on-style in-editor formatting.
The 3-day dip Day two and three feel slower than IDEA. That is where nearly everyone quits. The week-one drills are deliberately mechanical. No new plugins before week three.
DAP without projectName You will hit breakpoints and conclude the debugger is broken: evaluate throws, scopes returns empty, the Variables pane is blank. An attach entry with projectName set to the artifactId. Confirm via <leader>dc.
One JDWP connection per JVM After you disconnect the port is gone; re-attaching fails with attach timeout 30000. Restart the app to re-attach. suspend=y only when catching startup.

The four-week plan

Twenty drills, 20–30 minutes each, in a real terminal, on a scratch branch. No new plugins until week three. Each day ends with a one-line note written from memory — that file is the actual exam.

If you already know vi, do the placement check first

Days one and two are survival material and are mostly redundant for a vi user. Skipping them wholesale is also wrong: vi fluency in the 1990s sense leaves specific holes, and those holes are where the payoff is. Take five minutes and try each of these; anything not already automatic is a gap the plan must cover.

# Task If you reach for something else, you are missing
1 Jump to the definition of the symbol under the cursor gd (LSP)
2 List every caller of the method you are on gr
3 Open a file by fuzzy name among 2,820 <leader>ff
4 Live-grep the reactor as you type, target/ excluded <leader>fg
5 Project-wide search and replace, reviewed as a diff <leader>sr
6 Select, delete or yank the enclosing function vaf, daf, yaf (treesitter text objects)
7 Move to next or previous function ]f / [f
8 Stage or reset a single git hunk without leaving the buffer ]c, <leader>hp (gitsigns)
9 Rename a symbol across the reactor; organise imports <leader>cr, <leader>co
10 Jump back to where you were 20 edits ago Ctrl-o / Ctrl-i, marks, :earlier

Scoring: 8–10 automatic means skip week one and go to week three. 5–7 means one half-day on the gaps, then week three. Under 5 means do week one as written.

A compressed path for a vi user is about 1.5 days rather than 5: a half day on the gap items on real files, a half day on the build loop — -pl <module> -am -Pquick through the daemon wrapper, -Dtest=Class#method, and lazygit hunk by hunk — then straight to weeks three and four. vi teaches none of the build loop.

Week 1 — survival, navigation, and not touching the mouse

Day Drill Pass condition
1 nvim --clean then :Tutor twice, then vimtutor. Navigate a module with only h j k l w b e 0 $ gg G, Ctrl-d/u, :42. :Tutor completed twice, no arrows, no mouse.
2 Operators and text objects on real code: ciw, ci", ci(, di{, yap, >ip, gUiw, ~. Then . to repeat five times, u and Ctrl-r. Edit one method five ways, then undo all of it.
3 Project navigation: <leader>ff to a controller, <leader>fb, <leader>fg for a log string, cd (zoxide) between two checkouts. Reach any of the 8 top-level modules in under 5 s each.
4 LSP: gd into an interface, gr for every caller, K on a Spring annotation, <leader>ss for a symbol. List all call sites of one method without leaving nvim.
5 Layout: tmux session with three panes — nvim, mvnd test, tail -f logs — plus vim-tmux-navigator. Rebuild the three-pane layout from cold boot in under 60 s.

Week 2 — editing speed and git without a GUI

Day Drill Pass condition
6 Macros: qa … q, @a, @@, :%norm. Apply one mechanical edit to six similar DTOs. Six edits, one macro, under 2 minutes.
7 Project-wide refactor with <leader>sr across Maven config, env file and docs. Diff reviewed with delta, zero missed occurrences.
8 lazygit: stage by hunk, amend, split one commit into two, interactive rebase to squash. Three commits from two previously mixed changes.
9 gitsigns: ]c / [c between hunks, stage, reset, preview, blame line. Full self-review of your own branch without leaving nvim.
10 Time travel: marks across modules, jumplist, :earlier 10m, undo tree. Return to a file edited 20 minutes ago in one jump.

Week 3 — the real reactor: build, test, debug

Day Drill Pass condition
11 Reactor literacy: draw the module graph from the pom.xml files alone; help:active-profiles, dependency:tree -pl ninja-engine. A graph you can explain from memory.
12 Speed: hyperfine the cold mvn -Pquick verify against the daemon path, three runs each. Record the numbers. You know your cold and warm build times to the second.
13 Single-test loop: -Dtest=Class#method from a bound pane; break it deliberately; read the surefire report. Red to green in under a minute, twice.
14 DAP: start the app with -agentlib:jdwp, attach at :5005, set a conditional breakpoint, inspect a variable, evaluate an expression, hot-swap a method body. One real bug found with the debugger, not println.
15 Forensics: jps -lvm, jstack during a test run to find a blocked thread, jcmd GC.heap_info, a 30-second jfr record plus jfr summary. One page of notes on what the app’s threads actually do.

Week 4 — fluency, guardrails, and earning the right to skip IDEA

Day Drill Pass condition
16 Refactors: <leader>ca extract method, inline, generate constructor, add import on a real class. Write down the gaps against IDEA muscle memory. A written list of the five things IDEA still does better.
17 Guardrails: run verify without the skip flags, wire a linter to checkstyle if latency is acceptable, add a pre-commit hook running -Pquick verify -pl <module>. A commit the build would reject, caught locally first.
18 Search at scale: -U multiline, -t java, --type-add, -o with sort | uniq -c, --json piped to jq. Then rg --files | fzf for a file you can only describe. Three questions about the codebase answered with one rg each.
19 Ownership: move Java config into lua/plugins/java.lua, keymaps into lua/config/keymaps.lua, commit the config. Rebuild it from the repo under a throwaway config dir. nvim on a clean config dir behaves identically.
20 Capstone, 90 minutes, terminal only: add one endpoint — controller, service method, test. Debug it at :5005, run the module through the daemon, land a clean two-commit branch in lazygit. Done without opening IDEA.

Budget about 2.5 hours a week of drills on top of your normal work. Do not run the drills in IDEA “to go faster” — that resets the three-day dip every time.

What to read, and what to skip

Editor fundamentals, weeks one and two, about 4 hours total.

  1. :Tutor, built into nvim. Forty-five minutes, twice. Non-negotiable.
  2. Practical Vim, Drew Neil — chapters 1–4 only. The rest is reference; those four are the highest-value text on modal editing.
  3. TJ DeVries, “0 to LSP: Neovim RC From Scratch” — the one video that explains what LazyVim is doing. Watch it after week one, not before.
  4. ThePrimeagen, “Vim As Your Editor” 1–3 — speed drills.
  5. The kickstart.nvim README plus :help lua-guide, :help lsp, :help diagnostic — the vocabulary to read any config you find online.
  6. vim-be-good — five minutes a day of reflex training, paired with :Tutor.

Java and Neovim specifics, week three. The nvim-jdtls README and :help jdtls are the source of every java.* setting above. The eclipse.jdt.ls wiki settings reference is where the 200 real knobs live. LazyVim’s :LazyExtras, the lang.java extra and mason.nvim docs cover the plumbing. The nvim-dap wiki plus the java-debug-adapter README cover attach and launch semantics.

Build and shell, weeks two and three. man rg — properly, the -t, -g, -U and --json sections. The Maven 4 release notes on revision and consumer POMs, then the mvnd README. man tmux plus Ham Vocke’s crash course, now that you have a config worth learning copy-mode for. The lazygit keybinding docs, especially the rebase video.

Deliberately skipped: LunarVim and NvChad as abstractions on top of LazyVim, vim-plug tutorials that predate native LSP, anything teaching coc.nvim, and emacs rabbit holes.

When to still open IntelliJ

Four things, and that is the whole list: large-scale refactors across the reactor where you change a signature and propagate it, Spring bean and endpoint inspection (those two tool windows are genuinely good), profiler sessions on heap dumps, and database work if you use the DataGrip plugin.

Target steady state is IDEA open at most once a week for at most thirty minutes, and not on the launcher. Measure the goal rather than the vibe:

# your actual footprint, terminal-only day against IDEA day
ps -eo rss,comm --sort=-rss | head -12
free -h

The one-page cheat sheet

MOTION      w b e 0 $ gg G  f/t{char}  Ctrl-d/u  %  42G
EDIT        i a o O  ciw ci" ci(  di{ dap  yyp  gUiw  ~  .  u Ctrl-r
VISUAL      v V Ctrl-v  > <  :'<,'>norm  gv
SEARCH      / ? n N  * #  :%s/a/b/gc  grug-far=<leader>sr
FILES       <leader>ff fg fb fr  cd<dir> (zoxide)  :bd
LSP         gd gr gI gi K [d ]d  <leader>ca cr co cf ss
JAVA        <leader>cxm extract-method(visual)  cxv extract-var  cxc extract-const
            <leader>co organise-imports  cgs super-impl  tt test-class  tr test-nearest
GIT         lazygit (terminal pane — <leader>gg NOT mapped)  ]c [c  <leader>hp hs hr
WINDOWS     <C-w>v <C-w>s <C-w>h/j/k/l  <C-w>q  <C-w>=
DEBUG       <leader>db dc du  :DapEval   (attach: start with jdwp, then <leader>dc)
BUILD       mvnd-inner-loop -pl <m> -am -Pquick verify
            mvn -T 1C clean verify  (the gate)   mvn spring-boot:run -pl <app-module>
JVM         jps -lvm | jstack <pid> | jcmd <pid> GC.heap_info | jfr record

If you only do five things

  1. ripgrep, fd, zoxide, delta. Today. This alone makes the terminal feel faster than the IDE.
  2. Neovim, LazyVim, lang.java. Verify it by hovering any Lombok-generated getter — if it resolves with the right type, your Java setup is real.
  3. Learn -pl <module> -am -Pquick and the MVND daemon path. That is your green triangle, scripted.
  4. :Tutor twice, plus day two’s text-object drill. Modal editing without text objects is just a worse GUI.
  5. DAP attach in week three. The reason people run back to IDEA on day nine is “I couldn’t debug”, not “I couldn’t edit” — and the trap is projectName, not the breakpoints.

The single measurement I would pass on to anyone weighing this up: jdtls at 995 MB capped produced byte-identical hover and diagnostic results to 1893 MB uncapped. Nearly a gigabyte of my 7.6 GB was going to a heap ceiling nobody had thought about. If you are fighting a laptop for headroom on a large Java reactor and want a second opinion on where it is actually going, send me the details.

Samuel Jackson

Samuel Jackson

Senior Java Back End Developer & Contractor

Senior Java Back End Developer — Betfair Exchange API specialist, Spring Boot, AWS, and event-driven architecture. 25+ years delivering high-performance systems across betting, finance, energy, retail, and government. Available for Java contracting.