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
29 changes: 27 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
| 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(...)` 都能跳 |
| 老项目的 jar | 没有 Maven 的 Ant、Eclipse、MyEclipse 项目,读 `.classpath` 里列出的 jar(含 `M2_REPO` 变量和 `sourcepath`),以及 `lib/`、`libs/`、`WEB-INF/lib` 里的 jar |
| 生成代码 | 模块的 `target/generated-sources`(protobuf、注解处理器)和 Gradle 的 `build/generated` 也进索引,即使被 `.gitignore` 忽略 |
| 查找引用 | 只列出能跳回这个声明的出现,所以列表和在任一行上跳转的结果一致;`package`/`import` 不算引用 |
| 文档结构 | 类型、成员和嵌套类型的大纲(`textDocument/documentSymbol`) |
Expand All @@ -37,7 +38,14 @@
本地仓库没有的 jar 跳过;版本是范围或引用了不认识的属性时取本地最高的版本。工作区自己的模块不当依赖。
只预先列出每个 jar 的目录(Nacos 265 个 jar、4.4 万个类;ruoyi-vue-pro 824 个 jar、15 万个类,约 0.3 秒),
某个类第一次被查到时才读它的源码或生成存根;跳进去时把那一个文件写成只读副本,放在 `${stateDir}/dependency-sources`,
不写本地仓库。`pom.xml` 变了、或距上次读取超过 5 分钟,下一次扫描重新读取。
不写本地仓库。
- 工作区里的 jar:先按每个 Eclipse `.classpath` 的 `lib` 条目和 `M2_REPO` 变量条目的顺序,再加上路径往上三层内
有 `lib` 或 `libs` 目录的 jar(`WEB-INF/lib`、`lib/optional` 都算;测试资源里的 jar、`gradle-wrapper.jar`、
`target`/`build` 里的不算);工作区根目录和每个 `build.xml`、`.classpath`、`.project` 所在目录下的 `lib`、
`WEB-INF/lib`、`WebContent/WEB-INF/lib`、`WebRoot/WEB-INF/lib` 等即使被 `.gitignore` 忽略也会读。源码取
`.classpath` 的 `sourcepath`,或旁边的 `-sources.jar`、`-src.zip`;源码包不是按包名放的(比如整个源码树打的 zip)就不用,改用存根。
这些 jar 只读,不会被修改。
- `pom.xml`、`.classpath` 或上面任一个 jar 变了、或距上次读取超过 5 分钟,下一次扫描重新读取依赖。
- JDK:按 `JAVA_HOME` → 系统默认 JDK(macOS 的 `/usr/libexec/java_home`,其他系统 `PATH` 上的 `java`)→ 常见安装目录
(含 IntelliJ 的 `~/.jdks`、SDKMAN!)的顺序找第一个带 `src.zip` 的 JDK。只预先列出 zip 目录,某个类第一次被查到时才解析。
跳进 JDK 时把那一个源码文件解压成只读副本,放在 Lithe 为插件分配的状态目录(`${stateDir}`)里,卸载插件时一起删除;
Expand Down Expand Up @@ -76,9 +84,26 @@
其余是 JDT LS 能跳、本插件跳不了的泛型和 lambda(`list.get(i).split(...)`、`map.get(a).get(b)`、比较器里的
`o1.getPriority()`),以及两处要按泛型实参挑选的重载(`JsonUtils.toObj(String, ...)` 和 `toObj(byte[], ...)`)。

### 小众框架和老项目

每个项目 1500 个随机位置(种子 2),源码目录按项目实际布局取。跳到的目标全部逐个核对过,都落在该名字的声明行上:

| 项目 | 类型 | 能跳转的比例 | 跳转 p50 / p99 | 第一次跳转 | 内存(物理占用) |
| --- | --- | --- | --- | --- | --- |
| [JFinal](https://github.com/jfinal/jfinal) | 国产小众 Web 框架,Maven | 96.8% | 0.13 ms / 1.5 ms | 57 ms | 21 MiB |
| [Nutz](https://github.com/nutzam/nutz) | 国产小众框架,源码直接放 `src/` | 97.8% | 0.15 ms / 1.3 ms | 55 ms | 27 MiB |
| [Solon](https://github.com/opensolon/solon) | 国产小众框架,150 个模块 | 96.5% | 0.12 ms / 2.1 ms | 0.19 s | 36 MiB |
| [Apache Ant](https://github.com/apache/ant) | 2000 年起的老项目,`src/main/org` | 99.1% | 0.19 ms / 1.3 ms | 56 ms | 29 MiB |
| [Struts 1](https://github.com/apache/struts1) | 已归档的老框架,JSF/JSP | 97.5% | 0.16 ms / 1.8 ms | 44 ms | 22 MiB |
| [JForum 2](https://github.com/rafaelsteil/jforum2) | 已归档的 Ant 项目,jar 提交在 `WEB-INF/lib`,没有 pom | 99.3%(读工作区 jar 之前 95.9%) | 0.15 ms / 1.4 ms | 37 ms | 20 MiB |

剩下没跳到的主要是:依赖没在项目里声明或本地仓库没有(JFinal 的 Servlet 依赖在 pom 里被注释掉了,Struts 的
JSF 依赖没下载过)、数组的 `.length`(JDT LS 也不跳)、`java.util.Date` 这种全限定名里的包名段,以及 lambda 参数。

## 限制

- **依赖库只读 Maven 项目**:Gradle 项目的依赖还读不了(Gradle 的生成代码可以);只用本地仓库里已有的 jar,
- **依赖库只读 Maven 和工作区里的 jar**:Gradle 项目的依赖还读不了(Gradle 的生成代码可以);Ant 的
`build.xml` 里写在 `lib` 之外目录的 classpath 也读不了。只用本地仓库里已有的 jar,
从没构建过、依赖还没下载的项目跳不进依赖库。没有 `-sources.jar` 时打开签名存根,没有方法体和注释;
编译时没带调试信息的接口和抽象方法,参数名是 `arg0`、`arg1`。
- **没有泛型推断**:重载先按参数个数选,个数相同时按实参里写得出来的类型挑(字面量、强转、`new` 表达式、
Expand Down
9 changes: 6 additions & 3 deletions cmd/java-navigation/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ func main() {
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")
noDeps := flag.Bool("no-dependencies", false, "do not navigate into dependency jars (Maven dependencies and the jars the project keeps)")
flag.Bool("stdio", true, "speak LSP over standard input and output (the only transport)")
flag.Parse()
if *printVersion {
Expand All @@ -49,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 !*noDeps && *state != "" {
opt.Dependencies = true
if home, err := os.UserHomeDir(); err == nil {
opt.MavenRepo = deps.LocalRepository(home)
}
}
if *state == "" {
logger.Printf("no --state-dir: navigation into the JDK and dependency jars is off")
Expand Down
10 changes: 6 additions & 4 deletions cmd/nav-bench/main.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// Command nav-bench measures a Java language server on a real project: it
// samples identifiers in the project's src/main/java files (not keywords,
// samples identifiers in the project's src/main/java files (-under picks
// another source root; not keywords,
// literals, comments, package or import lines), asks the server for the
// definition of each one, then for the references of some, and reports the
// share that resolved per syntactic category, the latency of each request
Expand Down Expand Up @@ -349,14 +350,14 @@ func utf16Col(line string, byteOff int) int {
return len(utf16.Encode([]rune(line[:byteOff])))
}

func collect(root string) []sample {
func collect(root, under string) []sample {
out, err := exec.Command("git", "-C", root, "ls-files", "--cached", "--others", "--exclude-standard", "*.java").Output()
if err != nil {
panic(err)
}
var all []sample
for _, rel := range strings.Split(strings.TrimSpace(string(out)), "\n") {
if !strings.Contains(rel, "src/main/java/") {
if !strings.Contains(rel, under) {
continue
}
path := filepath.Join(root, rel)
Expand Down Expand Up @@ -400,13 +401,14 @@ func main() {
tag := flag.String("tag", "run", "output tag")
outDir := flag.String("out", ".", "output dir")
refs := flag.Int("refs", 20, "references requests")
under := flag.String("under", "src/main/java/", "sample only files whose path contains this (old layouts: src/)")
flag.Parse()
args := flag.Args()
if *root == "" || len(args) == 0 {
fmt.Fprintln(os.Stderr, "usage: nav-bench -root <project> [-seed n] [-n samples] [-jdtls] [-tag name] [-out dir] -- <server command>")
os.Exit(2)
}
all := collect(*root)
all := collect(*root, *under)
r := rand.New(rand.NewSource(*seed))
r.Shuffle(len(all), func(i, j int) { all[i], all[j] = all[j], all[i] })
if len(all) > *n {
Expand Down
4 changes: 3 additions & 1 deletion docs/releases/v0.0.1.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,14 @@
- 跳进本机 JDK 的 `src.zip`,并能在 JDK 源码里继续跳;JDK 源码以只读副本打开,放在插件状态目录里。
- 跳进 Maven 依赖库:从工作区的 `pom.xml`、父 POM、BOM 和本地仓库的 POM 按 Maven 的规则找出依赖 jar(不运行 Maven、不联网),
有 `-sources.jar` 打开源码,没有就打开从 `.class` 生成的签名存根;每个类第一次用到时才读取,副本同样只读、放在插件状态目录里。
- 没有 Maven 的老项目(Ant、Eclipse、MyEclipse):读 `.classpath` 列出的 jar 和 `lib/`、`WEB-INF/lib` 里的 jar,jar 只读、不修改。
- 一条声明里的多个变量(`int i = 0, size = s.length();`)都能跳。
- 生成代码(`target/generated-sources`、Gradle 的 `build/generated`)也进索引。
- 字符串字面量、类字面量、`Iface.super` 和 Lombok `log` 字段后面的调用能继续跳。
- 查找引用:只列出能跳回该声明的出现。
- 文档结构(大纲)。
- 在 Nacos 上和 JDT LS 对比 4500 个位置,4481 个跳到同一处,能跳转的比例 98.2%–99.3%(JDT LS 98.5%–99.5%);
ruoyi-vue-pro 98.4%–98.5%。跳转 p50 约 0.1 ms,常驻内存 60–90 MiB(JDT LS 约 3 GB)。
ruoyi-vue-pro 98.4%–98.5%;JFinal、Nutz、Solon、Ant、Struts 1、JForum 2 为 96.5%–99.3%,目标全部核对无误。跳转 p50 约 0.1 ms,常驻内存 60–90 MiB(JDT LS 约 3 GB)。
- 打开的文件按编辑器里未保存的内容索引。

平台:macOS(Apple Silicon、Intel)、Windows(x64、ARM64),每个平台一个 ZIP。
Expand Down
2 changes: 2 additions & 0 deletions internal/codenav/UPSTREAM.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@
(`Foo.class.getName()`)和 `Iface.super.m()`;`parse.go` 记下 Lombok 日志注解生成的 logger 类型
(`Type.LogType`,`@Slf4j` 是 `org.slf4j.Logger`),`findField` 给 `log` 字段带上这个类型,
`log.info(...)` 才能跳进日志库。
- 局部变量:`resolve.go` 的 `findLocal` 认出一条声明里逗号后面的变量(`int i = 0, size = s.length();`、
`int a, b;`),沿用第一个变量的类型;初始化表达式里括号更深的名字不算。
- 同步后运行 `go test ./...`。

`internal/jdk/find.go` 改编自 Viff 的 `internal/platform/jdk.go`(去掉了 Viff 的进程辅助函数,
Expand Down
27 changes: 27 additions & 0 deletions internal/codenav/resolve.go
Original file line number Diff line number Diff line change
Expand Up @@ -1095,16 +1095,38 @@ func (q *qctx) findLocal(name string, at int) (local, bool) {
decls = append(decls, local{k, ts, te})
names = append(names, p.txt(k))
}
// A declaration of several variables (int i = 0, n = s.length();)
// gives the names after its commas the first one's type (Lithe
// addition). multi is that declaration while its statement lasts, at
// paren depth multiDepth; the initializers' arguments are deeper.
var multi *local
depth, multiDepth := 0, 0
for k := b.start; k <= at && k < len(p.toks); k++ {
switch {
case p.is(k, "{"):
marks = append(marks, len(decls))
multi = nil
continue
case p.is(k, "}"):
if n := len(marks); n > 0 {
decls, names = decls[:marks[n-1]], names[:marks[n-1]]
marks = marks[:n-1]
}
multi = nil
continue
case p.is(k, "("):
depth++
continue
case p.is(k, ")"):
depth--
if multi != nil && depth < multiDepth {
multi = nil
}
continue
case p.is(k, ";"):
if multi != nil && depth <= multiDepth {
multi = nil
}
continue
case p.is(k, "->"):
if p.ident(k-1) && !p.is(k-2, ".") {
Expand Down Expand Up @@ -1138,6 +1160,11 @@ func (q *qctx) findLocal(name string, at int) (local, bool) {
}
if ts := q.typeStartBack(te - 1); ts >= 0 {
add(k, ts, te)
if !p.is(nx, ")") && !p.is(nx, ":") {
multi, multiDepth = &local{k, ts, te}, depth
}
} else if multi != nil && depth == multiDepth && p.is(k-1, ",") && !p.is(nx, ":") {
add(k, multi.typeS, multi.typ)
}
}
for i := len(decls) - 1; i >= 0; i-- {
Expand Down
49 changes: 49 additions & 0 deletions internal/codenav/usages_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -185,3 +185,52 @@ func contains(s []string, v string) bool {
}
return false
}

// Every variable of a declaration of several is a local with the
// declaration's type; names inside the initializers are not (Lithe
// addition).
func TestEveryVariableOfADeclarationIsALocal(t *testing.T) {
const path = "src/a/M.java"
src := `package a;

class M {
int b;
String name() { return ""; }
M make(int k, int b) { return this; }
int run(String key, java.util.List<M> list) {
int s2, s5;
for (int i = 0, size = key.length(); i < size; i++) {
s5 = i;
}
M x = list.get(0), y = make(s2, b), z;
z = y;
return s5 + y.name().length() + b;
}
}
`
ix := NewIndex()
ix.Put(ParseFile(path, []byte(src)))
line := func(needle string) int {
t.Helper()
return strings.Count(src[:strings.Index(src, needle)], "\n")
}
for _, c := range []struct {
needle string
skip int
wantLocal bool
wantLine int
}{
{"s5 = i", 0, true, line("int s2, s5;")},
{"size; i++", 0, true, line("for (int i")},
{"z = y", 0, true, line("M x =")},
{"y;\n", 0, true, line("M x =")},
{"name().length", 0, false, line("String name()")},
// b in make(s2, b) is the field, not a third variable of M x.
{"b;\n }\n}", 0, false, line("int b;")},
} {
l, ok := ix.Resolve(path, []byte(src), offsetOf(t, src, c.needle, 0, c.skip))
if !ok || l.Local != c.wantLocal || l.Line != c.wantLine {
t.Errorf("%q: %+v ok=%v, want local=%v line %d", c.needle, l, ok, c.wantLocal, c.wantLine)
}
}
}
43 changes: 34 additions & 9 deletions internal/deps/jars.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Package deps lets navigation follow names into a project's dependency
// jars. It finds the jars of the Maven projects in a working tree in the
// local repository (MavenJars), lists the classes each one declares and,
// local repository (MavenJars) and the jars a tree keeps itself (TreeJars),
// lists the classes each one declares and,
// for a class a lookup needs, returns Java source: the entry of the jar's
// -sources.jar when it was downloaded, else a stub of signatures written
// from the class files. Only the archives' directories are read up front;
Expand Down Expand Up @@ -127,18 +128,42 @@ func (s *Set) Paths() []string { return s.paths }
// as the .java file a stub of them is.
func listJar(j Jar) (*archive, []string, error) {
a := &archive{Jar: j}
a.file = j.Path
classes, err := a.list()
if err != nil {
return nil, nil, err
}
if j.Sources != "" {
a.file, a.sources = j.Sources, true
if entries, err := a.list(); err == nil && len(entries) > 0 {
return a, entries, nil
s := &archive{Jar: j, file: j.Sources, sources: true}
if entries, err := s.list(); err == nil && rootedLike(entries, classes) {
return s, entries, nil
}
}
a.file, a.sources = j.Path, false
entries, err := a.list()
if err != nil {
return nil, nil, err
return a, classes, nil
}

// rootedLike reports whether a source archive lays its files out by package
// from its root, as the class jar does: an archive of a source tree
// ("lib-1.0-src/src/main/java/a/B.java") would name every class wrongly.
// One class of the jar found at its place is enough, since a sources jar may
// leave out generated classes. A jar without classes accepts any sources.
func rootedLike(sources, classes []string) bool {
if len(sources) == 0 {
return false
}
if len(classes) == 0 {
return true
}
have := make(map[string]bool, len(sources))
for _, e := range sources {
have[e] = true
}
for _, c := range classes {
if have[c] {
return true
}
}
return a, entries, nil
return false
}

func (a *archive) list() ([]string, error) {
Expand Down
Loading
Loading