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.
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.
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).
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:
tutor runtime plugin in disabled_plugins, so :Tutor — week one, day one of the plan below — does not exist. Re-enable it.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.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.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.
projectName trapI 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.jsonscopes returns empty, so the Variables pane looks blank and the debugger looks brokenjava-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.
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.
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=trueis not optional.fmt-maven-pluginbinds toprocess-sources, so even a plainmvn compilereformats 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}'
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.
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 | 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. |
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.
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.
| 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. |
| 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. |
| 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. |
| 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.
Editor fundamentals, weeks one and two, about 4 hours total.
:Tutor, built into nvim. Forty-five minutes, twice. Non-negotiable.kickstart.nvim README plus :help lua-guide, :help lsp, :help diagnostic — the vocabulary to read any config you find online.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.
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
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
lang.java. Verify it by hovering any Lombok-generated getter — if it resolves with the right type, your Java setup is real.-pl <module> -am -Pquick and the MVND daemon path. That is your green triangle, scripted.:Tutor twice, plus day two’s text-object drill. Modal editing without text objects is just a worse GUI.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.