Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Test data is compared byte for byte (golden stubs) or is binary (jars):
# never convert its line endings on checkout.
internal/deps/testdata/** -text
88 changes: 46 additions & 42 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
# Java Navigation for Lithe

为 [Lithe](https://github.com/1lck/Lithe-IDEA) 提供**不依赖 JDT LS 的轻量 Java 跳转**:跳转到定义、查找引用和文档结构。
插件自带一个用 Go 写的小型语言服务器,不需要 JDK 运行时、不导入 Maven/Gradle 构建,打开项目后第一次跳转约 0.2 秒就能用,
常驻内存几十 MB(JDT LS 通常要 3–5 GB,首次导入几十秒到几分钟)。
为 [Lithe](https://github.com/1lck/Lithe-IDEA) 提供**不依赖 JDT LS 的轻量 Java 跳转**:跳转到定义、查找引用和文档结构,
能跳进 JDK 和 Maven 依赖库。插件自带一个用 Go 写的小型语言服务器,不需要 JDK 运行时、不运行 Maven,
打开项目后第一次跳转约 0.2–0.5 秒就能用,常驻内存 60–90 MiB(同一个项目 JDT LS 要 3 GB,导入要 1 分钟)。
这是一个**社区插件**,按插件契约 v0 编写。

> English: lightweight Java navigation for Lithe without JDT LS — go to definition, find references and the
> document outline, served by a small Go language server that keeps an index of declarations only. No JDK
> runtime, no build import, tens of MiB of memory.
> document outline, into the JDK and Maven dependency jars, served by a small Go language server that keeps
> an index of declarations only. No JDK runtime, no build import, 60–90 MiB of memory.

> **预发布**:按插件契约 v0(`schemaVersion: 0`、`pluginAPIVersion: 0`)编写,只能装进新版 Lithe 桌面端
> (`desktop-v*` 预发布),旧版 macOS Lithe 无法安装。
Expand All @@ -19,6 +19,8 @@
| 跳转到定义 | 类型、方法、构造器、字段、枚举常量、局部变量、参数、类型参数、注解及其属性;按导入、同包、通配导入、嵌套、继承链、调用参数个数和实参类型解析,支持调用链 `a.b().c` 和 `Foo.<T>bar()` |
| Lombok | `@Data`/`@Getter`/`@Setter` 生成的访问器跳到字段,`@Builder`、`@Slf4j`、构造器注解跳到注解本身(与 JDT LS 一致) |
| 跳进 JDK | 项目里找不到的名字到本机 JDK 的 `src.zip` 里找,`String`、`List`、`hashCode()` 等都能跳,并能在 JDK 源码里继续跳 |
| 跳进依赖库 | Maven 项目的依赖(含传递依赖)从本地仓库读:下载过 `-sources.jar` 的打开源码,否则打开从 `.class` 生成的签名存根(类、成员类、字段、构造器、方法,参数名取自调试信息),并能在里面继续跳;`@Schema(description = ...)`、`log.info(...)`、`StrUtil.isEmpty(...)` 都能跳 |
| 生成代码 | 模块的 `target/generated-sources`(protobuf、注解处理器)和 Gradle 的 `build/generated` 也进索引,即使被 `.gitignore` 忽略 |
| 查找引用 | 只列出能跳回这个声明的出现,所以列表和在任一行上跳转的结果一致;`package`/`import` 不算引用 |
| 文档结构 | 类型、成员和嵌套类型的大纲(`textDocument/documentSymbol`) |
| 未保存的修改 | 打开的文件按编辑器里的内容索引,未保存的新方法也能从其他文件跳过去 |
Expand All @@ -29,6 +31,13 @@
- 第一次跳转时才建索引:在 Git 仓库里用 `git ls-files` 列出已跟踪和未跟踪的文件(跳过 `.gitignore` 忽略的),
否则遍历目录并跳过隐藏目录与 `target`、`build`、`out`、`bin`、`node_modules`。超过 2 MiB 的文件跳过。
- 之后每次查询如果距上次扫描超过 2 秒,就按文件大小和修改时间增量重扫,其他工具改的文件也能反映出来。
- 依赖库:读工作区里的每个 `pom.xml` 和它的父 POM、导入的 BOM,再沿本地仓库(`~/.m2/settings.xml` 的
`localRepository`,默认 `~/.m2/repository`)里的 POM 找传递依赖,规则和 Maven 一致:最近的声明优先、根模块的
`dependencyManagement` 决定版本、`test`/`provided`/可选依赖不传递、`exclusions` 生效。不运行 Maven、不联网,
本地仓库没有的 jar 跳过;版本是范围或引用了不认识的属性时取本地最高的版本。工作区自己的模块不当依赖。
只预先列出每个 jar 的目录(Nacos 265 个 jar、4.4 万个类;ruoyi-vue-pro 824 个 jar、15 万个类,约 0.3 秒),
某个类第一次被查到时才读它的源码或生成存根;跳进去时把那一个文件写成只读副本,放在 `${stateDir}/dependency-sources`,
不写本地仓库。`pom.xml` 变了、或距上次读取超过 5 分钟,下一次扫描重新读取。
- JDK:按 `JAVA_HOME` → 系统默认 JDK(macOS 的 `/usr/libexec/java_home`,其他系统 `PATH` 上的 `java`)→ 常见安装目录
(含 IntelliJ 的 `~/.jdks`、SDKMAN!)的顺序找第一个带 `src.zip` 的 JDK。只预先列出 zip 目录,某个类第一次被查到时才解析。
跳进 JDK 时把那一个源码文件解压成只读副本,放在 Lithe 为插件分配的状态目录(`${stateDir}`)里,卸载插件时一起删除;
Expand All @@ -38,56 +47,46 @@

## 实测(Apple M4,macOS,JDK 17)

在 `src/main/java` 里随机取 1500 个标识符(不含关键字、字符串里的词、`package`/`import` 行),两个种子:
用 `cmd/nav-bench` 在 `src/main/java` 里随机取 1500 个标识符(不含关键字、字符串里的词、`package`/`import` 行),
逐个请求跳转到定义,再对其中 20 个方法调用请求查找引用。Nacos 取 3 个种子,ruoyi-vue-pro 取 2 个:

| 项目 | Java 文件 | 能跳转的比例 | 其中跳进 JDK | 单次 p50 / p99 | 第一次跳转(含建索引) | 服务器常驻内存 |
| --- | --- | --- | --- | --- | --- | --- |
| Nacos | 2886 | 93.9% / 94.9% | 333 / 326 | 0.07 ms / 0.8–1.0 ms | 0.12 s | 约 74–76 MiB |
| ruoyi-vue-pro | 6355 | 54.3% / 53.3% | 198 / 207 | 0.06 ms / 0.5–0.6 ms | 0.17 s | 约 75–85 MiB |
| 项目 | 能跳转的比例 | 单次 p50 / p99 | 第一次跳转(含建索引、读依赖) | 查找引用 p50 / 最慢 | 内存(物理占用 / RSS) |
| --- | --- | --- | --- | --- | --- |
| Nacos | 98.7% / 98.2% / 99.3% | 0.12–0.18 ms / 1.7–3.3 ms | 0.24–0.42 s | 61–81 ms / 0.18 s | 62–69 MiB / 91–101 MiB |
| ruoyi-vue-pro | 98.4% / 98.5% | 0.11 ms / 2.2–2.7 ms | 0.46–0.50 s | 92–94 ms / 0.22 s | 91 MiB / 106–108 MiB |

查找引用 20 次,p50 约 56 ms,最慢 0.13 s。
物理占用是活动监视器显示的“内存”;RSS 还包括进程已经还给系统、系统还没回收的页。只看项目本身
(`--no-dependencies`),Nacos 跑完同样的请求约 47 MiB。

### 和 JDT LS 对比跳转目标
### 和 JDT LS 对比

在 Nacos 上用同样的取样方式取 3 组、每组 1500 个位置,同时问 JDT LS(Maven 导入完成后)和本插件,比较跳到的文件和行
(跳进 JDK 的按类比较,因为两边的 JDK 版本和源码不一定相同):
同样的 3 组 Nacos 位置,同时问 JDT LS 1.60(Homebrew,默认参数,Maven 导入完成后)和本插件,比较跳到的文件和行
(跳进 JDK 或依赖库的按类比较,因为两边的 JDK 版本和源码不一定相同):

| 结果 | 种子 2 | 种子 7 | 种子 11 |
| --- | --- | --- | --- |
| 两边跳到同一处(项目内) | 1099 | 1071 | 1086 |
| 两边跳到 JDK 的同一个类 | 326 | 337 | 327 |
| 两边都没有结果 | 8 | 14 | 17 |
| 只有 JDT LS 能跳:依赖库 | 59 | 72 | 64 |
| 只有 JDT LS 能跳:项目内或 JDK | 8 | 3 | 5 |
| 只有本插件能跳 | 0 | 2 | 0 |
| 两边都跳了但不一样 | 0 | 1 | 1 |
| | 本插件 | JDT LS |
| --- | --- | --- |
| 能跳转的比例 | 98.7% / 98.2% / 99.3% | 98.8% / 98.5% / 99.5% |
| 两边跳到同一处 | 1494 / 1494 / 1493(共 1500) | |
| 启动到可用 | 第一次跳转 0.24–0.42 s | 59–61 s(Maven 导入,本地仓库已有缓存,不含下载) |
| 跳转 p50 / p99 | 0.12–0.18 ms / 1.7–3.3 ms | 3.0–3.9 ms / 20–39 ms,最慢 1.4 s |
| 查找引用 p50 / 最慢 | 61–81 ms / 0.18 s | 128–211 ms / 4.7 s |
| 内存(物理占用 / RSS) | 62–69 MiB / 91–101 MiB | 2.96–3.0 GB / 3.1–3.3 GB |

“两边都跳了但不一样”的两处都不是跳错:`org.xml.sax.Attributes` 本身就在 JDK 里(JDT LS 把它当成依赖库),
`Properties.putAll` 是 JDK 9 起才有的重写(这个模块用 JDK 8 编译,JDT LS 跳到 `Hashtable.putAll`,本插件读的是 JDK 17)。
只有 JDT LS 能跳的项目内位置是 `target/generated-sources` 里 protobuf 生成的类、泛型推断(`list.get(i).getName()`)
和依赖库类型继承来的方法(`e.getMessage()`,`e` 的类型来自 Spring)。

对比中发现并已修复的问题:参数个数相同的重载取了第一个(`new AccessException("...")` 跳到了 `AccessException(int)`),
显式类型实参调用 `RestResult.<T>builder()`、限定名注解 `@ExtractorManager.Extractor(...)` 和可变参数参数
`String... keys` 跳不了。

宿主联调:用 Lithe 桌面端固定的 Rust Core(`47c9859`)校验 `plugin.json`、规划 `packaged` 启动、经 Core 启动语言服务器,
会话进入 `ready` 并声明 `definition`、`references`、`documentSymbols`,三种请求都返回正确位置;跳进 JDK 时只在 `${stateDir}`
写入解压副本,插件包文件的 SHA-256 前后一致。ruoyi-vue-pro 跳不了的几乎全是依赖库的注解和它们的属性:
Swagger 的 `@Schema(description, example, requiredMode)`、Lombok 的 `@Data`、校验注解 `@NotNull`、EasyExcel、Spring 注解等,
见下面的“限制”。
剩下的差异:方法引用 `Timestamp::compareTo`、`Resolver::resolve` 本插件跳到方法本身,JDT LS 跳到函数式接口;
其余是 JDT LS 能跳、本插件跳不了的泛型和 lambda(`list.get(i).split(...)`、`map.get(a).get(b)`、比较器里的
`o1.getPriority()`),以及两处要按泛型实参挑选的重载(`JsonUtils.toObj(String, ...)` 和 `toObj(byte[], ...)`)。

## 限制

- **不读依赖库**:Spring、MyBatis、Lombok、Swagger 等依赖里的声明找不到(返回“没有定义”,不会跳错)。
计划:从 Maven/Gradle 插件取 classpath,按需读取 `sources.jar`,没有源码时显示只有签名的存根。
- **没有类型推断**:重载先按参数个数选,个数相同时按实参里写得出来的类型挑(字面量、强转、`new` 表达式、
- **依赖库只读 Maven 项目**:Gradle 项目的依赖还读不了(Gradle 的生成代码可以);只用本地仓库里已有的 jar,
从没构建过、依赖还没下载的项目跳不进依赖库。没有 `-sources.jar` 时打开签名存根,没有方法体和注释;
编译时没带调试信息的接口和抽象方法,参数名是 `arg0`、`arg1`。
- **没有泛型推断**:重载先按参数个数选,个数相同时按实参里写得出来的类型挑(字面量、强转、`new` 表达式、
变量和字段声明的类型、能跟下去的调用链),都看不出时取第一个;lambda 参数的类型和泛型替换后的返回类型推不出来,
例如 `list.get(0).getName()` 里的 `getName`、`users.stream().map(u -> u.getName())` 里的 `getName`;
以字面量为限定符的调用(`"".trim()`)也推不出类型。
按泛型实参才能区分的重载也会取第一个。
- **只认一个 JDK**:用的是上面顺序找到的 JDK,不一定是项目编译用的版本。
- **引用不含重写和实现**:查找方法的引用不包括子类重写的方法和通过接口调用到的其他声明。
- **生成代码**:注解处理器生成到 `target/generated-sources` 的类通常被 `.gitignore` 忽略,不进索引。
- **和 Java Support(JDT LS)同时安装**:两个插件都为 Java 声明语言服务器,宿主怎么分配请求还没有定义,暂时不要同时启用。

## 开发
Expand All @@ -98,8 +97,13 @@ Swagger 的 `@Schema(description, example, requiredMode)`、Lombok 的 `@Data`
go test ./... # 单元测试和端到端协议测试
go test -race ./...
node scripts/package.mjs # 交叉编译四个平台并打包到 dist/
go run ./cmd/nav-bench -h # 在真实项目上测命中率、延迟和内存(见文件开头的说明)
```

`internal/deps/testdata/fixture` 里的 jar 由同目录的 `build.sh` 用 javac 编出(需要 JDK 17+),测试只读提交的 jar;
改了 `internal/deps/stub.go` 的输出时,运行 `go test ./internal/deps -run Golden -update` 更新
`testdata/stubs`,并把 `stubFormat` 加一,让旧版本解压的副本不再被复用。

`dist/` 里每个平台一个 zip(`darwin-arm64`、`darwin-x64`、`windows-x64`、`windows-arm64`),包内只有
`plugin.json`、`bin/java-navigation`(Windows 是 `bin/java-navigation.exe`,对应平台包的 `plugin.json` 也写这个路径)、
`LICENSE` 和 `THIRD_PARTY_NOTICES.md`。
Expand Down
19 changes: 18 additions & 1 deletion cmd/java-navigation/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,18 +10,32 @@ import (
"fmt"
"log"
"os"
"runtime/debug"

"github.com/Lithe-IDEA/lithe-plugin-java-navigation/internal/deps"
"github.com/Lithe-IDEA/lithe-plugin-java-navigation/internal/jdk"
"github.com/Lithe-IDEA/lithe-plugin-java-navigation/internal/lsp"
)

// version is set at build time with -ldflags "-X main.version=...".
var version = "dev"

// gcPercent is the garbage collector's target unless GOGC is set. The server
// keeps an index of tens of MiB and every query allocates scratch; the
// default of 100 lets the heap grow to twice the index before collecting.
// Measured on Nacos (1,500 definitions and 20 reference searches, Apple M4):
// 100 ends at a 68 MiB footprint, 50 at 58 MiB, 25 at 47 MiB, with the
// median definition at 0.11, 0.11 and 0.12 ms.
const gcPercent = 25

func main() {
if os.Getenv("GOGC") == "" {
debug.SetGCPercent(gcPercent)
}
state := flag.String("state-dir", "", "writable directory for extracted JDK sources (the host's ${stateDir})")
printVersion := flag.Bool("version", false, "print the version and exit")
noJDK := flag.Bool("no-jdk", false, "do not navigate into the JDK's sources")
noDeps := flag.Bool("no-dependencies", false, "do not navigate into the jars of Maven dependencies")
flag.Bool("stdio", true, "speak LSP over standard input and output (the only transport)")
flag.Parse()
if *printVersion {
Expand All @@ -35,8 +49,11 @@ func main() {
if !*noJDK && *state != "" {
opt.FindJDK = jdk.FindJDK
}
if home, err := os.UserHomeDir(); err == nil && !*noDeps && *state != "" {
opt.MavenRepo = deps.LocalRepository(home)
}
if *state == "" {
logger.Printf("no --state-dir: navigation into the JDK is off")
logger.Printf("no --state-dir: navigation into the JDK and dependency jars is off")
}
err := lsp.New(os.Stdin, os.Stdout, opt).Run()
switch {
Expand Down
Loading
Loading