1. 先想明白一件事:Neovim内置LSP并不是开箱即用的Java补全
1.1 内置LSP客户端与语言服务器是如何分工的
第一次在Neovim里写Java的人,十有八九都经历过这种尴尬:打开一个多模块项目,语法高亮是有了,可一敲点号,补全列表空空如也。于是很多人直接下结论——Neovim不适合写Java,还是回IntelliJ吧。这个结论放在三五年前还算客观,但Neovim 0.5 正式引入内置LSP之后,局面早就变了。
需要先纠正一个常见误解:所谓“内置LSP”,并不是说Neovim自己认识Java语法,而是它内置了一套完整的LSP客户端实现,也就是vim.lsp模块。这套客户端负责启动语言服务器进程、按JSON-RPC协议收发消息、管理诊断信息、处理跳转和补全请求。真正负责理解Java代码的是另一头的语言服务器,它不在Neovim里,是一个独立进程。对Java而言,目前最成熟、几乎是唯一可用的服务器就是Eclipse JDT Language Server,文件名叫 jdtls。
打个比方,LSP这套链路很像外卖平台:Neovim是点餐的顾客,语言服务器是后厨,补全、跳转、重命名这些“菜品”全由后厨做出来,顾客只是负责下单和上桌。顾客换了餐厅,菜还是后厨做的。所以你在Neovim里写Java,本质上的工作量是两件事:把后厨请过来(安装jdtls),再让顾客和后厨之间的下单流程跑通(配置vim.lsp客户端)。Neovim内置LSP帮你解决了协议那一坨麻烦,但并没有帮你请后厨。
1.2 Java语言服务器的特殊性:为什么jdtls比gopls笨重
同样是配置LSP,写Go、写Python、写Lua,基本一两条命令就能搞定:gopls、pyright、lua_ls都是体积小、启动快的轻量服务器。到了Java这里突然画风突变,jdtls不仅体积大,首次启动还慢,依赖还多,这真不是Neovim的锅,而是Java语言本身太“重”了。
Java的类型系统牵扯的东西非常多:类型推断、方法重载、泛型擦除、继承体系、注解处理,这些都需要语言服务器理解完整的类型信息才能给出正确的补全和跳转。更麻烦的是,Java项目的依赖并不是看几个源文件就能拼出来的,它必须解析Maven的pom.xml、Gradle的build.gradle,把classpath完整拉起来,甚至要读懂一大堆第三方jar包里的类和方法签名。jdtls底层直接基于Eclipse的ECJ编译器实现,它启动时要做的工作是建立整个项目的类型索引,而这个索引的质量直接决定你敲代码时的体验。
我今天把这套链路完整拆开讲一遍,包括环境准备、jdtls启动参数的每个细节、lspconfig接入方式、nvim-cmp补全联动,以及我真实踩过的坑。文章面向的读者是不满足于IDE、想在Neovim里把Java开发环境搭起来的同学,也适合想弄明白LSP底层到底在干什么的人。
2. 环境准备:JDK版本、构建工具和目录规划
2.1 JDK版本:一版没对齐就起不来
很多人刚接触jdtls时第一个莫名其妙的报错就是:Java文件打开了,状态栏提示语言服务器启动失败,日志里一堆ClassNotFoundException或者UnsupportedClassVersionError。翻了一大圈才发现是JDK版本太老。
jdtls对JDK版本有硬性要求,最低要JDK 17,而新版发布包基本都在往JDK 21靠拢。Java 8、11这个年代的机器上,jdtls压根起不来。这里有个迷惑点:你可能系统里装了好几个JDK,java -version看到的版本和echo $JAVA_HOME指向的版本未必一致,而jdtls启动时用的到底是哪个,取决于启动脚本里写的是java还是$JAVA_HOME/bin/java。
我先给一个最简单的检查流程:
java -version echo $JAVA_HOME which java三个结果最好对齐到同一个JDK。我自己现在固定用JDK 21 LTS,一个版本解决所有兼容问题。如果你项目中恰好需要老版本编译,用Maven或Gradle的toolchains单独指定即可,没必要在jdtls这边纠结。
2.2 构建工具与项目结构:classpath从哪来
jdtls要正常工作,必须拿到项目的classpath。它不会自己瞎猜,而是直接调用Maven或Gradle去解析。所以一个Java项目最好在根目录有pom.xml或build.gradle,并且建议保留项目自带的mvnw或gradlewwrapper脚本。
为什么要强调wrapper?因为jdtls解析项目时如果找不到wrapper,就会用系统全局的mvn或gradle。不同项目依赖的构建工具版本不同,一旦解析结果不一致,最容易出现的现象就是:这个项目打开补全正常,另一个项目一打开就报一堆“项目不可编译”的错误,或者明明依赖都拉好了,某些第三方jar里的类就是补全不出来。
还有一个在实际使用中非常容易被忽视的细节:项目路径尽量保持纯英文、不要带空格。JDT的索引对特殊字符的支持一直不算稳定,我见过不止一个人项目塞在带中文和空格的目录里,补全时好时坏、跳转跳不到正确的类,查了半天最后把项目挪到~/workspace下面,问题直接消失。
2.3 为jdtls规划独立的工作区目录
jdtls启动时需要一个-data参数,这个参数指定的是它的工作区目录,大家可以把它理解成Eclipse的工作空间。工作区里保存的东西包括项目索引、配置历史、类路径缓存等,这也是jdtls记忆力的来源。
很多人第一次配置jdtls时,图省事给所有Java项目共享同一个-data目录。短时间看不出问题,等你在A项目写完代码、切回B项目继续写的时候,补全里可能冒出A项目的类名,跳转还可能跳到同名类的另一个版本,因为JDT把所有项目的信息混在一个索引里了。更严重的情况下,不同项目依赖的Java版本或Lombok版本不同,jdtls按照旧项目的配置去解析新项目,直接导致新项目标红一片。
我的习惯是:一个项目一个独立工作区,放到~/.cache/jdtls-workspace/<项目名>下面。后面接入Neovim时,这个路径会按当前目录动态生成,不用手动管。这也是jdtls配置里我认为最值得花心思的一环。
| 检查项 | 建议值 | 原因 |
|---|---|---|
| JDK版本 | JDK 17+,推荐21 LTS | jdtls硬性要求,版本太低直接起不来 |
| JAVA_HOME | 与PATH中java版本一致 | 避免启动脚本用了错误的Java |
| 构建工具 | 项目内mvnw/gradlew | 保证classpath解析一致性 |
| 项目路径 | 纯英文、无空格 | 规避JDT索引路径兼容问题 |
| -data目录 | 按项目独立 | 防止多项目索引串味 |
3. jdtls启动参数逐条拆解:data目录、configuration和JVM内存
3.1 拿到正确的jdtls发布包
在配置之前,先把jdtls本体下载下来。你可以直接到Eclipse官方发布页找最新版压缩包,也可以用Mason之类Neovim插件管理器安装。我更建议手动下载解压,因为你能清楚看到它的目录结构,后续排错时心里有数。
解压后的jdtls目录大概长这样:
jdtls/ ├── bin/ ├── config_linux/ ├── config_mac/ ├── config_win/ ├── features/ └── plugins/plugins目录里有一个org.eclipse.equinox.launcher_*.jar,这就是整个jdtls的启动入口;config_linux、config_mac、config_win分别对应三个平台的OSGi配置文件目录。启动时必须选择与当前系统匹配的那一个,用Linux却指定了config_win,大概率会报无法加载配置的错误。
3.2 启动命令的每一段参数都在干什么
下面这段命令是jdtls手动启动的核心,我先贴出来,然后逐段拆解:
/usr/lib/jvm/java-21-openjdk-amd64/bin/java \ -Declipse.application=org.eclipse.jdt.ls.core.id1 \ -Dosgi.bundles.defaultStartLevel=4 \ -Declipse.product=org.eclipse.jdt.ls.core.product \ -Dlog.level=ERROR \ -Dfile.encoding=UTF-8 \ -javaagent:/path/to/lombok.jar \ -Xms1g -Xmx2g \ --add-modules=ALL-SYSTEM \ --add-opens java.base/java.util=ALL-UNNAMED \ --add-opens java.base/java.lang=ALL-UNNAMED \ -jar /path/to/jdtls/plugins/org.eclipse.equinox.launcher_*.jar \ -configuration /path/to/jdtls/config_linux \ -data /path/to/jdtls-workspace/my-project这一段命令有四个关键部分。
第一部分是两个-D开头的参数,它们是Eclipse OSGi框架和应用标识。-Declipse.application=org.eclipse.jdt.ls.core.id1告诉Equinox“我要启动的应用是JDT Language Server”,-Declipse.product=org.eclipse.jdt.ls.core.product是产品标识。这些参数不写,jdtls会把自己当成一个普通的Eclipse RCP程序来启动,行为完全不对。
第二部分是JVM参数。-Dlog.level=ERROR把日志压到最低,不然jdtls的日志会刷屏刷到怀疑人生。-Dfile.encoding=UTF-8是给项目文件编码兜底,如果不设置,某些系统默认GBK环境下,中文注释和字符串会变成乱码。-javaagent:/path/to/lombok.jar是Lombok注入的关键,项目里用了Lombok就必须带这一行,否则jdtls根本识别不了你自己加的那些@Data、@Builder注解生成的方法。
第三部分是最容易让人困惑的--add-modules=ALL-SYSTEM和--add-opens系列参数。JDK 9开始模块系统启用强封装,老框架会访问不了JDK内部类。jdtls内部大量使用反射手段,不加这些参数,启动时或者运行中就会冒出一堆IllegalAccessError。网上很多教程从老版本抄过来,漏掉了这部分,属于最常见的启动失败根源之一。
第四部分是整条命令的骨架:-jar指定Equinox launcher,-configuration指定平台配置文件目录,-data指定工作区目录。这几项的含义可以用一个简单对照表说清楚:
| 参数 | 作用 | 选错/漏掉的后果 |
|---|---|---|
-jar org.eclipse.equinox.launcher_*.jar | 启动OSGi框架 | 缺了它整个进程无法启动 |
-configuration config_linux | 指定平台OSGi配置 | 不同平台弄混会加载失败 |
-data /path/to/workspace | 指定索引工作区 | 路径选错会导致索引串味 |
-javaagent:lombok.jar | 让Lombok参与JDT语法分析 | getter/setter补全不出来 |
--add-opens系列 | 绕过JDK模块强封装 | 运行期反射报错 |
-Xms/-Xmx | JVM堆内存设置 | 内存不足时索引频繁卡顿 |
3.3 JVM内存与日志参数
jdtls是个吃内存的大户,尤其在首次索引大型Maven项目时,给的内存不够它就直接罢工。我这边一般给-Xms1g -Xmx2g,如果你打开的是大型多模块项目,-Xmx4g也不算夸张。反正在Neovim里没有IDE那些花哨界面的开销,内存给jdtls单独用,压力不算大。
日志这块,日常开发用-Dlog.level=ERROR足够,排错时才需要临时调到INFO。调整方法很简单,改一下启动参数然后重启语言服务器就行。另外,第一次把一个老项目接入jdtls时,建议先手动跑一遍命令行启动,观察日志里有没有报错,确认进程稳定后再回Neovim里操作。这个习惯帮我省了大量排查时间。
4. 把jdtls接入Neovim:lspconfig配置的两种写法和on_attach细节
4.1 为什么我推荐在ftplugin里配置而不是init.lua
很多教程把jdtls塞到init.lua里,和一堆其他语言服务器一起用lspconfig统一配置。这样当然能跑,但Java场景下有一个绕不开的问题:每个项目的工作区-data目录应该独立。放在init.lua里,所有项目共享一份配置,很难自然地按项目名生成工作区目录。
更合理的做法是写在after/ftplugin/java.lua里。这个文件只会在打开Java文件时被加载,天然契合“不同项目需要不同工作区”的需求。Neovim的getcwd()能拿到当前项目根路径,用它作为工作区目录的命名依据,一个项目一个目录,互不干扰。
4.2 一份完整的jdtls接入配置
下面这份是我目前在用的配置骨架,可以直接抄,只要把两处路径替换成自己的JDK和jdtls路径即可:
-- ~/.config/nvim/after/ftplugin/java.lua local jdtls = "/path/to/jdtls" local jdk = "/usr/lib/jvm/java-21-openjdk-amd64/bin/java" local launcher_jar = vim.fn.glob(jdtls .. "/plugins/org.eclipse.equinox.launcher_*.jar", true) -- 根据当前项目名生成独立工作区目录 local project_name = vim.fn.fnamemodify(vim.fn.getcwd(), ":t") local workspace_dir = vim.fn.stdpath("cache") .. "/jdtls-workspace/" .. project_name -- 补全能力协商,这一步必须放在setup之前 local capabilities = vim.lsp.protocol.make_client_capabilities() capabilities = require("cmp_nvim_lsp").default_capabilities(capabilities) local config = { cmd = { jdk, "-Declipse.application=org.eclipse.jdt.ls.core.id1", "-Dosgi.bundles.defaultStartLevel=4", "-Declipse.product=org.eclipse.jdt.ls.core.product", "-Dlog.level=ERROR", "-Dfile.encoding=UTF-8", "-javaagent:/path/to/lombok.jar", -- 用Lombok才需要 "-Xms1g", "-Xmx2g", "--add-modules=ALL-SYSTEM", "--add-opens", "java.base/java.util=ALL-UNNAMED", "--add-opens", "java.base/java.lang=ALL-UNNAMED", "--add-opens", "java.base/java.text=ALL-UNNAMED", "--add-opens", "java.desktop/java.awt.font=ALL-UNNAMED", "-jar", launcher_jar, "-configuration", jdtls .. "/config_linux", -- 按平台选 "-data", workspace_dir, }, capabilities = capabilities, -- 识别Java项目根目录的文件 root_dir = require("lspconfig").util.root_pattern("pom.xml", "build.gradle", "settings.gradle", ".git"), on_attach = function(client, bufnr) -- 下面单独讲按键映射 end, } require("lspconfig").jdtls.setup(config)这里有一个细节值得单独说明:root_dir决定了Neovim判断“项目根目录”的依据。我同时写了pom.xml和build.gradle,这样Maven和Gradle项目都能正确识别。.git作为兜底,哪怕项目没有构建文件,也不会让语言服务器彻底迷路。
如果你通过lspconfig.settings给其他语言配置做统一管理,不要忘了jdtls这个capabilities变量的来源是cmp_nvim_lsp.default_capabilities(),它会向服务器声明“我这个客户端支持snippet补全、支持补全项的resolve请求”,没有这个声明,后面Java的很多补全体验会打折。
4.3 on_attach:键位、诊断和CodeLens
on_attach回调是每次语言服务器附着到当前缓冲区时执行的函数,入口参数是client和bufnr。我在Java环境里习惯绑定这些键位:
local opts = { noremap = true, silent = true, buffer = bufnr } vim.keymap.set("n", "K", vim.lsp.buf.hover, opts) vim.keymap.set("n", "gd", vim.lsp.buf.definition, opts) vim.keymap.set("n", "gD", vim.lsp.buf.declaration, opts) vim.keymap.set("n", "gr", vim.lsp.buf.references, opts) vim.keymap.set("n", "gi", vim.lsp.buf.implementation, opts) vim.keymap.set("n", "<F2>", vim.lsp.buf.rename, opts) vim.keymap.set("n", "<F3>", vim.lsp.buf.code_action, opts) vim.keymap.set("n", "<F4>", vim.lsp.buf.signature_help, opts)键位的映射逻辑和IDE的习惯尽量保持一致。gr查引用是Java开发里最常用的操作,尤其是在重构或者排查“这个方法到底被哪里调用”的时候,用好它能省掉大量肉眼搜索的时间。<F2>重命名是jdtls做得很好的功能之一,它能跨文件同步改到所有引用点,比你在编辑器里手动全文替换靠谱得多。
CodeLens这个功能容易被忽略。Java的CodeLens可以显示“X references”“Y implementations”这类信息,Neovim较新版本已经能做到自动刷新,不需要像老教程那样挂一堆CursorHold事件去手动refresh。如果你用的还是旧版Neovim,vim.lsp.codelens.refresh()手动触发一次也无妨。
4.4 Lombok:为什么它在Neovim里经常不生效
搜过Lombok相关问题的人大概率见过这句话:you aren't using a compiler supported by lombok, so lombok will not work。这句话的本意是Lombok版本和javac版本不匹配,但在Neovim配置LSP的场景下,还有一个更隐蔽的问题:jdtls是一个独立进程,Lombok必须作为javaagent注入到它的JVM里才会生效。
很多人只在项目的pom.xml里加了Lombok依赖,IDE里一切正常,换到Neovim里@Data标注的类一片标红,getter/setter也补全不出来。问题往往出在jdtls的启动参数上——没有-javaagent:/path/to/lombok.jar这一行。上面配置里的那一行参数几乎就是Java项目接jdtls时最容易漏掉的东西。如果项目里还用了Lombok较新的特性,记得确认Lombok版本和JDK版本兼容,JDK 21好歹要Lombok 1.18.30起步。
5. 补全层面打通:nvim-cmp与LSP capabilities的联动
5.1 capabilities:客户端能力协商是补全的第一道门
如果你已经按上面配置启动了jdtls,但发现补全列表弹不出来,先别急着怀疑配置哪里写错了,大概率是capabilities没设置对。
LSP的补全流程是这样的:客户端启动时先告诉服务器“我支持哪些能力”,服务器根据这份能力清单决定返回什么形式的补全项。jdtls返回的补全项里有一部分是snippet形式,比如构造器自动生成、方法重写补全,这类补全需要客户端声明支持snippetSupport。用vim.lsp.protocol.make_client_capabilities()生成的默认能力没有打开这个开关,必须通过require("cmp_nvim_lsp").default_capabilities()去合并覆盖。
我自己就犯过这个错误:刚接好的时候,普通字段方法补全都能用,但只要补全项带snippet,选中的结果是乱的,后来才发现是能力协商的问题。所以上面所有配置里那句capabilities = require("cmp_nvim_lsp").default_capabilities(capabilities)一定要保留,它就是打开Java高级补全大门的钥匙。
5.2 nvim-cmp最小配置
jdtls只是把补全数据通过LSP协议送到Neovim,真正负责渲染列表、接收按键、展示文档的,是补全引擎。我推荐用nvim-cmp,它性能好、生态全,和内置LSP是天然搭档。
需要准备这几个插件:
hrsh7th/nvim-cmp:补全主引擎hrsh7th/cmp-nvim-lsp:提供LSP补全源,同时提供default_capabilitieshrsh7th/cmp-buffer:缓冲区补全,补充一些LSP覆盖不到的文本片段hrsh7th/cmp-path:路径补全
最小配置如下:
local cmp = require("cmp") cmp.setup({ snippet = { expand = function(args) require("luasnip").lsp_expand(args.body) end, }, mapping = cmp.mapping.preset.insert({ ["<Tab>"] = cmp.mapping.confirm({ select = true }), ["<C-n>"] = cmp.mapping.select_next_item(), ["<C-p>"] = cmp.mapping.select_prev_item(), }), sources = cmp.config.sources({ { name = "nvim_lsp" }, { name = "buffer" }, { name = "path" }, }), })关于snippet部分要特别说一句:很多Neovim新手会觉得“我又不用snippet插件,这一段跳过不就行了”,实际在Java场景下不行。jdtls返回的补全项里有相当一部分依赖snippet引擎,比如重写父类方法时生成的整个方法体模板。没有snippet引擎,这些补全项就无法正确展开。最简单的选择是LuaSnip,配置量极小,插入方式也和IDE习惯相近。
5.3 Java补全到底能补出什么
配置跑通之后,Java的补全体验会有一个质的提升。我这里列举几个真实场景:
输入System.,会补出out、err、currentTimeMillis()、nanoTime()等静态成员。
输入一个局部变量的名字然后敲点号,jdtls能根据类型推断补出该类型的所有公共方法,比如list.会推荐add()、get()、size()、stream()等。
如果你声明了List<String> list = new Arr,补全列表会直接给出ArrayList<>()构造器,而且能根据泛型参数帮你带出正确的模板。
还有一个我觉得特别爽的:在类里输入override或implements相关代码时,调用<F3>打开code action,jdtls会列出所有可重写的父类方法,选中之后自动生成带@Override注解的完整方法签名。这一套操作下来,Java开发里最枯燥的模板代码部分基本可以告别手动敲了。
5.4 自动导入与补全排序调整
Java补全里还有一个高频需求是自动导入缺失的类。jdtls把自动导入做成了code action,在on_attach里已经绑定了<F3>,光标停在报错处按一下,就能看到Source Action... Organize Imports之类的选项,选中后自动补齐import,或者清理掉没用的import。手动按一次有点别扭,但用顺了也能接受。
如果你觉得某些静态方法应该在补全列表里排得更靠前,jdtls也支持配置favoriteStaticMembers,但这个参数配置起来比较绕,需要在lspconfig的settings里写Java LSP的原生配置。我建议先用默认排序,等实际问题不够用再去调,毕竟这类优先级优化对日常开发的影响有限。
6. 我在五个真实场景里踩过的坑
6.1 启动失败:ClassNotFoundException / UnsupportedClassVersionError
这个坑我踩过不止一两次,症状是打开Java文件后,Language Server一直处于starting状态,日志里抛出各种ClassNotFoundException或UnsupportedClassVersionError。
我现在的排查链路很固定。第一步,不要看Neovim的报错,而是把jdtls启动命令从配置里复制出来,在终端手动执行一遍,观察原始输出。这一步能直接过滤掉一大半问题,因为Neovim的LSP日志经过封装后,很多关键错误信息会被吞掉。第二步,确认JDK版本,java -version和echo $JAVA_HOME必须同时满足jdtls的要求。第三步,检查-configuration参数对应的平台目录是否和当前系统一致,Linux机器指定成了config_win,进程一般起不来。第四步,如果之前能跑、突然不行了,把工作区目录里的.metadata删掉重来,这个问题在新老版本jdtls升级时尤其常见。
6.2 多个项目共用-data目录导致补全串味
这也是一个典型的“能用但不好用”的坑。症状是:先打开A项目,工作正常,再打开B项目时补全里混着A项目的类名,跳转定义还会跳到A项目里同名类的文件上。
根因就是我前面说的,-data目录是jdtls的索引工作区,它把A、B两个项目的所有类信息都塞到了同一个仓库里,于是两个项目的类型空间被合并了。解决办法也很直接:为每个项目生成独立的-data目录,也就是配置里workspace_dir那段动态拼接的逻辑。另外要接受一个现实:一个Neovim实例同时维护两个Java项目本来就不太稳,切项目时最好:LspRestart重启一次语言服务器,让jdtls重新加载新项目的工作区。
6.3 项目路径带着中文或空格,索引死活不对
如果你把项目放在D:\代码\我的项目或者/Users/xxx/My Project/这种路径下,jdtls可能表现得非常诡异——补全时好时坏、引用查找时多时少、偶尔还爆出一堆路径相关的警告。
JDT对路径中特殊字符的支持远比看上去脆弱。我的建议是涉及Java项目的目录一律走纯英文路径,空格也要避免。这个约束看上去有点强制,但比起和JDT较劲,耗费的时间完全不值得。如果因为团队协作等客观原因项目必须放在特殊路径下,至少保证jdtls的工作区-data目录是纯英文,也能缓解一部分问题。
6.4 首次打开Maven项目补全一片空白,其实是在索引
很多人第一次配置完,打开一个大型Maven项目,发现前几分钟补全列表一直空白,CPU还嗡嗡转,就以为自己配置错了,开始反复重装插件。
真实情况是jdtls在做首次索引:下载依赖、建立类型体系、生成全局索引。这个过程在大型项目上可能需要两到三分钟,期间补全缓慢甚至空白都是正常的。判断标准很简单,把日志级别临时改成-Dlog.level=INFO,能看到它在不停分析类文件,那就没坏,等索引完成自然就顺畅了。一个实操加速技巧是:在打开Neovim之前,先在终端跑一次mvn compile -DskipTests或./gradlew compileJava -x test,让Maven/Gradle把依赖提前拉到位,jdtls就不用边索引边现拉jar包了。
| 症状 | 可能原因 | 处理方式 |
|---|---|---|
| 启动失败,ClassNotFound | JDK版本过旧或平台配置目录不对 | 升级JDK 21,检查config目录 |
| 补全串味 | 多项目共用-data目录 | 按项目分配独立工作区 |
| 补全时好时坏 | 路径含中文/空格 | 改为纯英文路径 |
| 首次打开空白 | jdtls在做依赖索引 | 预编译项目,等待索引完成 |
| Lombok标红 | 未加载javaagent | cmd里加-javaagent:lombok.jar |
6.5 Lombok标红但编译能过,补全不出来
这是Java项目接jdtls时最让人抓狂的问题之一:代码在Maven里能编译通过,在Neovim里却一片标红,@Data注解下的getter/setter被当成不存在的字段。
原因要从两条链路来看。编译时Lombok通过javac的注解处理器在AST阶段生成方法,所以编译能过;而jdtls做语义分析时,必须让Lombok以javaagent方式注入到自己的JVM进程中,它才看得见那些注解生成的方法。如果你的jdtls启动参数里没有-javaagent:/path/to/lombok.jar,就会出现“编译能过,编辑器标红”的诡异状态。排查时先检查这个参数,再检查项目的pom.xml里是否配置了annotationProcessorPaths,两者都满足后重启语言服务器。另外Lombok和JDK的版本匹配同样重要,JDK版本越高,需要的Lombok版本也越新,老版本的Lombok在JDK 21下不光补全不出来,编译阶段就会先报错。
最后说点个人感受。很多人一提到Neovim写Java就摇头,我能理解,jdtls的配置和学习成本确实比IDE的打开即用高一大截。但一旦把这个环境配好,日常开发的快乐也是实打实的:启动快、无弹窗、不占内存,补全和跳转稳稳当当地工作。我的建议是第一次配置别追求一步到位,先跑通最基本的补全加跳转,用一两周适应,再逐步加Lombok、加调试、加Spring Boot相关的扩展。环境是给自己用的,顺手比六边形战士重要得多。如果你在配置过程中也遇到了奇怪的报错,不妨先把你启动jdtls的那条原生命令复制出来手动跑一遍,90%的问题在这一步就能看出端倪。