From 268ac37b12023e317a6b0565cc83725f3af22ae1 Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Thu, 12 Nov 2026 19:43:37 -0800 Subject: [PATCH 1/2] feat: navigate into Maven dependency jars, with less memory Names the working tree does not declare now resolve into the project's Maven dependencies, not only the JDK. Measured with the new nav-bench on 1,500 random identifiers per seed: Nacos 98.2-99.3% resolve (JDT LS 98.5-99.5%), 1493-1494 of 1500 to the same place as JDT LS; ruoyi-vue-pro 98.4-98.5% (was about 72%). - internal/deps reads the working tree's pom.xml files with their parents and imported BOMs and follows dependencies through the local repository as Maven mediates them (nearest wins, root management sets versions, test/provided/optional are not transitive, exclusions apply), without running Maven or downloading anything. - Only each jar's directory is listed up front. A class is read the first time a lookup needs it: from the -sources.jar when it was downloaded, else as a stub of signatures written from its class files (member classes, constants, annotation defaults, parameter names from the debug information). Copies go read-only under the state directory. - Sources generated into target/generated-sources and Gradle's build/generated are indexed although .gitignore leaves them out. - String and class literals, Iface.super and Lombok log fields type the calls on them, so "a".trim(), Foo.class.getName(), log.info(...) and Iface.super.m() resolve. - Memory: indexing reuses token and read buffers (198 MB allocated on Nacos down to 86 MB), returns the scan's garbage to the system, and the server runs with GOGC=25 unless GOGC is set (68 MiB footprint down to 47 MiB without dependencies). Library paths are short and keyed by a substring of themselves, so 154,000 dependency classes cost about 20 MiB; with dependencies Nacos ends at 62-69 MiB, ruoyi at 91 MiB (JDT LS: 3 GB). - cmd/nav-bench is the measuring tool behind the README's numbers. --no-dependencies turns dependency jars off. Co-Authored-By: Claude Opus 5.5 --- README.md | 88 +-- cmd/java-navigation/main.go | 19 +- cmd/nav-bench/main.go | 570 ++++++++++++++++ docs/releases/v0.0.1.md | 10 +- internal/codenav/UPSTREAM.md | 11 + internal/codenav/doc.go | 5 +- internal/codenav/lex.go | 8 +- internal/codenav/library.go | 46 +- internal/codenav/library_test.go | 93 +++ internal/codenav/parse.go | 41 +- internal/codenav/resolve.go | 18 +- internal/deps/classfile.go | 543 ++++++++++++++++ internal/deps/deps_test.go | 367 +++++++++++ internal/deps/jars.go | 354 ++++++++++ internal/deps/maven.go | 608 ++++++++++++++++++ internal/deps/signature.go | 241 +++++++ internal/deps/stub.go | 558 ++++++++++++++++ internal/deps/stub_test.go | 67 ++ internal/deps/testdata/fixture/build.sh | 15 + .../testdata/fixture/helper-2.0-sources.jar | Bin 0 -> 631 bytes internal/deps/testdata/fixture/helper-2.0.jar | Bin 0 -> 772 bytes internal/deps/testdata/fixture/lib-1.0.jar | Bin 0 -> 8595 bytes .../nodebug/com/example/lib/NoDebug.java | 7 + .../sources/com/example/helper/Helper.java | 8 + .../src/com/example/lib/Container.java | 61 ++ .../fixture/src/com/example/lib/Level.java | 28 + .../fixture/src/com/example/lib/Point.java | 14 + .../fixture/src/com/example/lib/Process.java | 5 + .../src/com/example/lib/Repository.java | 17 + .../fixture/src/com/example/lib/Runner.java | 11 + .../fixture/src/com/example/lib/Schema.java | 26 + internal/deps/testdata/stubs/Container.java | 30 + internal/deps/testdata/stubs/Level.java | 9 + internal/deps/testdata/stubs/NoDebug.java | 7 + internal/deps/testdata/stubs/Point.java | 8 + internal/deps/testdata/stubs/Process.java | 6 + internal/deps/testdata/stubs/Repository.java | 11 + internal/deps/testdata/stubs/Runner.java | 8 + internal/deps/testdata/stubs/Schema.java | 15 + internal/deps/version.go | 110 ++++ internal/jdk/sources.go | 9 +- internal/lsp/server.go | 4 + internal/workspace/deps_test.go | 237 +++++++ internal/workspace/files.go | 68 +- internal/workspace/workspace.go | 240 ++++++- 45 files changed, 4495 insertions(+), 106 deletions(-) create mode 100644 cmd/nav-bench/main.go create mode 100644 internal/deps/classfile.go create mode 100644 internal/deps/deps_test.go create mode 100644 internal/deps/jars.go create mode 100644 internal/deps/maven.go create mode 100644 internal/deps/signature.go create mode 100644 internal/deps/stub.go create mode 100644 internal/deps/stub_test.go create mode 100755 internal/deps/testdata/fixture/build.sh create mode 100644 internal/deps/testdata/fixture/helper-2.0-sources.jar create mode 100644 internal/deps/testdata/fixture/helper-2.0.jar create mode 100644 internal/deps/testdata/fixture/lib-1.0.jar create mode 100644 internal/deps/testdata/fixture/nodebug/com/example/lib/NoDebug.java create mode 100644 internal/deps/testdata/fixture/sources/com/example/helper/Helper.java create mode 100644 internal/deps/testdata/fixture/src/com/example/lib/Container.java create mode 100644 internal/deps/testdata/fixture/src/com/example/lib/Level.java create mode 100644 internal/deps/testdata/fixture/src/com/example/lib/Point.java create mode 100644 internal/deps/testdata/fixture/src/com/example/lib/Process.java create mode 100644 internal/deps/testdata/fixture/src/com/example/lib/Repository.java create mode 100644 internal/deps/testdata/fixture/src/com/example/lib/Runner.java create mode 100644 internal/deps/testdata/fixture/src/com/example/lib/Schema.java create mode 100644 internal/deps/testdata/stubs/Container.java create mode 100644 internal/deps/testdata/stubs/Level.java create mode 100644 internal/deps/testdata/stubs/NoDebug.java create mode 100644 internal/deps/testdata/stubs/Point.java create mode 100644 internal/deps/testdata/stubs/Process.java create mode 100644 internal/deps/testdata/stubs/Repository.java create mode 100644 internal/deps/testdata/stubs/Runner.java create mode 100644 internal/deps/testdata/stubs/Schema.java create mode 100644 internal/deps/version.go create mode 100644 internal/workspace/deps_test.go diff --git a/README.md b/README.md index 09be0a7..f79b339 100644 --- a/README.md +++ b/README.md @@ -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 无法安装。 @@ -19,6 +19,8 @@ | 跳转到定义 | 类型、方法、构造器、字段、枚举常量、局部变量、参数、类型参数、注解及其属性;按导入、同包、通配导入、嵌套、继承链、调用参数个数和实参类型解析,支持调用链 `a.b().c` 和 `Foo.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`) | | 未保存的修改 | 打开的文件按编辑器里的内容索引,未保存的新方法也能从其他文件跳过去 | @@ -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}`)里,卸载插件时一起删除; @@ -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.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 声明语言服务器,宿主怎么分配请求还没有定义,暂时不要同时启用。 ## 开发 @@ -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`。 diff --git a/cmd/java-navigation/main.go b/cmd/java-navigation/main.go index d7054fd..7964aee 100644 --- a/cmd/java-navigation/main.go +++ b/cmd/java-navigation/main.go @@ -10,7 +10,9 @@ 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" ) @@ -18,10 +20,22 @@ import ( // 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 { @@ -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 { diff --git a/cmd/nav-bench/main.go b/cmd/nav-bench/main.go new file mode 100644 index 0000000..ff4e3a5 --- /dev/null +++ b/cmd/nav-bench/main.go @@ -0,0 +1,570 @@ +// 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, +// 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 +// and the server's memory. It is how the numbers in the README were taken, +// for this server and for JDT LS on the same samples. +// +// go run ./cmd/nav-bench -root ~/code/nacos -seed 2 -tag jn -out /tmp/bench -- \ +// "$(go env GOPATH)/bin/java-navigation" --state-dir /tmp/bench/state +// go run ./cmd/nav-bench -root ~/code/nacos -seed 2 -jdtls -tag jdtls -out /tmp/bench -- \ +// jdtls -data /tmp/bench/jdtls-data +// +// It writes .summary.json, .results.json (every sample and where it +// went) and .stderr (the server's log) to -out. The project must be a +// Git working tree. Memory is the resident set of the server and its child +// processes; on macOS the physical footprint, which Activity Monitor shows, +// is reported too. Run it on an idle machine: it measures wall-clock time. +package main + +import ( + "bufio" + "encoding/json" + "flag" + "fmt" + "io" + "math/rand" + "os" + "os/exec" + "path/filepath" + "regexp" + "runtime" + "sort" + "strconv" + "strings" + "sync" + "sync/atomic" + "time" + "unicode" + "unicode/utf16" +) + +var keywords = map[string]bool{} + +func init() { + for _, k := range strings.Fields(`abstract assert boolean break byte case catch char class const continue default do double else enum extends final finally float for goto if implements import instanceof int interface long native new package private protected public return short static strictfp super switch synchronized this throw throws transient try void volatile while true false null var record yield sealed permits non`) { + keywords[k] = true + } +} + +type sample struct { + file, uri string + line, col int // col in UTF-16 + name, kind string + text string +} + +type client struct { + in io.WriteCloser + out *bufio.Reader + mu sync.Mutex + nextID int64 + pending sync.Map // id -> chan json.RawMessage + ready chan struct{} + once sync.Once + busy atomic.Int64 + lastMsg atomic.Int64 +} + +func (c *client) send(v any) { + b, _ := json.Marshal(v) + c.mu.Lock() + fmt.Fprintf(c.in, "Content-Length: %d\r\n\r\n", len(b)) + c.in.Write(b) + c.mu.Unlock() +} + +func (c *client) request(method string, params any, timeout time.Duration) (json.RawMessage, error) { + id := atomic.AddInt64(&c.nextID, 1) + ch := make(chan json.RawMessage, 1) + c.pending.Store(id, ch) + c.send(map[string]any{"jsonrpc": "2.0", "id": id, "method": method, "params": params}) + select { + case r := <-ch: + return r, nil + case <-time.After(timeout): + c.pending.Delete(id) + return nil, fmt.Errorf("timeout %s", method) + } +} + +func (c *client) notify(method string, params any) { + c.send(map[string]any{"jsonrpc": "2.0", "method": method, "params": params}) +} + +func (c *client) loop() { + for { + n := 0 + for { + line, err := c.out.ReadString('\n') + if err != nil { + return + } + line = strings.TrimSpace(line) + if line == "" { + break + } + if strings.HasPrefix(strings.ToLower(line), "content-length:") { + n, _ = strconv.Atoi(strings.TrimSpace(line[15:])) + } + } + buf := make([]byte, n) + if _, err := io.ReadFull(c.out, buf); err != nil { + return + } + c.lastMsg.Store(time.Now().UnixNano()) + var m struct { + ID json.RawMessage `json:"id"` + Method string `json:"method"` + Params json.RawMessage `json:"params"` + Result json.RawMessage `json:"result"` + } + json.Unmarshal(buf, &m) + if m.Method != "" && len(m.ID) > 0 { // server request: answer null + c.send(map[string]any{"jsonrpc": "2.0", "id": m.ID, "result": nil}) + continue + } + if m.Method == "language/status" { + var p struct{ Type, Message string } + json.Unmarshal(m.Params, &p) + if p.Type == "ServiceReady" { + c.once.Do(func() { close(c.ready) }) + } + continue + } + if m.Method == "$/progress" { + var p struct { + Value struct{ Kind string } `json:"value"` + } + json.Unmarshal(m.Params, &p) + switch p.Value.Kind { + case "begin": + c.busy.Add(1) + case "end": + c.busy.Add(-1) + } + continue + } + if len(m.ID) > 0 && m.Method == "" { + id, _ := strconv.ParseInt(string(m.ID), 10, 64) + if ch, ok := c.pending.LoadAndDelete(id); ok { + ch.(chan json.RawMessage) <- m.Result + } + } + } +} + +// footprintKiB is macOS's physical footprint (what Activity Monitor shows), +// summed over pid and its descendants; RSS keeps pages the process already +// returned with MADV_FREE until the system reclaims them. It is 0 elsewhere. +func footprintKiB(pid int) int { + if runtime.GOOS != "darwin" { + return 0 + } + total := 0 + for _, p := range tree(pid) { + out, _ := exec.Command("top", "-l", "1", "-stats", "mem", "-pid", strconv.Itoa(p)).Output() + lines := strings.Split(strings.TrimSpace(string(out)), "\n") + v := strings.TrimSpace(lines[len(lines)-1]) + mult := 1.0 + switch { + case strings.HasSuffix(v, "G"): + mult = 1024 * 1024 + case strings.HasSuffix(v, "M"): + mult = 1024 + case strings.HasSuffix(v, "K"): + mult = 1 + case strings.HasSuffix(v, "B"): + mult = 1.0 / 1024 + } + f, _ := strconv.ParseFloat(strings.TrimRight(v, "GMKB+-"), 64) + total += int(f * mult) + } + return total +} + +func tree(pid int) []int { + out, _ := exec.Command("ps", "-A", "-o", "pid=,ppid=").Output() + kids := map[int][]int{} + for _, l := range strings.Split(string(out), "\n") { + f := strings.Fields(l) + if len(f) != 2 { + continue + } + p, _ := strconv.Atoi(f[0]) + pp, _ := strconv.Atoi(f[1]) + kids[pp] = append(kids[pp], p) + } + res := []int{pid} + for i := 0; i < len(res); i++ { + res = append(res, kids[res[i]]...) + } + return res +} + +func rssKiB(pid int) int { + // Sum the server and its descendants (jdtls is a python wrapper around java). + out, _ := exec.Command("ps", "-A", "-o", "pid=,ppid=,rss=").Output() + kids := map[int][]int{} + rss := map[int]int{} + for _, l := range strings.Split(string(out), "\n") { + f := strings.Fields(l) + if len(f) != 3 { + continue + } + p, _ := strconv.Atoi(f[0]) + pp, _ := strconv.Atoi(f[1]) + r, _ := strconv.Atoi(f[2]) + kids[pp] = append(kids[pp], p) + rss[p] = r + } + total := 0 + var walk func(int) + walk = func(p int) { + total += rss[p] + for _, k := range kids[p] { + walk(k) + } + } + walk(pid) + return total +} + +var identRe = regexp.MustCompile(`[A-Za-z_$][A-Za-z0-9_$]*`) + +// mask blanks comments, strings and char literals, keeping offsets. +func mask(src []byte) []byte { + b := append([]byte(nil), src...) + for i := 0; i < len(b); i++ { + switch { + case b[i] == '/' && i+1 < len(b) && b[i+1] == '/': + for ; i < len(b) && b[i] != '\n'; i++ { + b[i] = ' ' + } + case b[i] == '/' && i+1 < len(b) && b[i+1] == '*': + for ; i < len(b) && !(b[i] == '*' && i+1 < len(b) && b[i+1] == '/'); i++ { + if b[i] != '\n' { + b[i] = ' ' + } + } + if i+1 < len(b) { + b[i], b[i+1] = ' ', ' ' + i++ + } + case b[i] == '"' && i+2 < len(b) && b[i+1] == '"' && b[i+2] == '"': + b[i], b[i+1], b[i+2] = ' ', ' ', ' ' + for i += 3; i+2 < len(b) && !(b[i] == '"' && b[i+1] == '"' && b[i+2] == '"'); i++ { + if b[i] != '\n' { + b[i] = ' ' + } + } + if i+2 < len(b) { + b[i], b[i+1], b[i+2] = ' ', ' ', ' ' + i += 2 + } + case b[i] == '"' || b[i] == '\'': + q := b[i] + b[i] = ' ' + for i++; i < len(b) && b[i] != q && b[i] != '\n'; i++ { + if b[i] == '\\' { + b[i] = ' ' + i++ + } + if i < len(b) { + b[i] = ' ' + } + } + if i < len(b) && b[i] == q { + b[i] = ' ' + } + } + } + return b +} + +func prevNonSpace(s string, i int) (byte, int) { + for i--; i >= 0; i-- { + if s[i] != ' ' && s[i] != '\t' { + return s[i], i + } + } + return 0, -1 +} + +func nextNonSpace(s string, i int) byte { + for ; i < len(s); i++ { + if s[i] != ' ' && s[i] != '\t' { + return s[i] + } + } + return 0 +} + +func classify(line string, start, end int, name string) string { + pc, pi := prevNonSpace(line, start) + nc := nextNonSpace(line, end) + prevWord := "" + if pi >= 0 { + j := pi + 1 + k := j + for k > 0 && (isIdent(line[k-1])) { + k-- + } + prevWord = line[k:j] + } + allCaps := strings.ToUpper(name) == name && strings.ContainsAny(name, "ABCDEFGHIJKLMNOPQRSTUVWXYZ") + upper := unicode.IsUpper(rune(name[0])) + switch { + case pc == '@': + return "注解 @X" + case pc == '.' && nc == '(': + return "方法调用 a.b()" + case nc == '(' && prevWord == "new": + return "构造器 new X()" + case nc == '(' && (prevWord == "" || prevWord == "return" || pc == '(' || pc == ',' || pc == '=' || pc == '!' || pc == '?' || pc == ':' || pc == '{' || pc == ';' || pc == '+' || pc == '-' || pc == '&' || pc == '|' || prevWord == "this" || prevWord == "super"): + return "无限定调用 b()" + case nc == '(': + return "方法/构造器声明" + case pc == '.' && allCaps: + return "常量/枚举 X.Y" + case pc == '.' && upper: + return "限定类型 a.B" + case pc == '.': + return "字段/成员 a.b" + case allCaps && len(name) > 1: + return "常量/枚举 Y" + case upper: + return "类型名" + default: + return "变量/参数/字段" + } +} + +func isIdent(c byte) bool { + return c == '_' || c == '$' || (c >= '0' && c <= '9') || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') +} + +func utf16Col(line string, byteOff int) int { + return len(utf16.Encode([]rune(line[:byteOff]))) +} + +func collect(root 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/") { + continue + } + path := filepath.Join(root, rel) + src, err := os.ReadFile(path) + if err != nil { + continue + } + masked := strings.Split(string(mask(src)), "\n") + orig := strings.Split(string(src), "\n") + for ln, l := range masked { + t := strings.TrimSpace(l) + if strings.HasPrefix(t, "package ") || strings.HasPrefix(t, "import ") { + continue + } + for _, m := range identRe.FindAllStringIndex(l, -1) { + name := l[m[0]:m[1]] + if keywords[name] || (m[0] > 0 && l[m[0]-1] >= '0' && l[m[0]-1] <= '9') { + continue + } + all = append(all, sample{file: path, uri: "file://" + path, line: ln, col: utf16Col(orig[ln], m[0]), name: name, + kind: classify(l, m[0], m[1], name), text: strings.TrimSpace(orig[ln])}) + } + } + } + return all +} + +type location struct { + URI string `json:"uri"` + TargetURI string `json:"targetUri"` + Range struct { + Start struct{ Line int } `json:"start"` + } `json:"range"` +} + +func main() { + root := flag.String("root", "", "project root") + seed := flag.Int64("seed", 2, "sample seed") + n := flag.Int("n", 1500, "samples") + jdtls := flag.Bool("jdtls", false, "wait for JDT LS import") + tag := flag.String("tag", "run", "output tag") + outDir := flag.String("out", ".", "output dir") + refs := flag.Int("refs", 20, "references requests") + flag.Parse() + args := flag.Args() + if *root == "" || len(args) == 0 { + fmt.Fprintln(os.Stderr, "usage: nav-bench -root [-seed n] [-n samples] [-jdtls] [-tag name] [-out dir] -- ") + os.Exit(2) + } + all := collect(*root) + 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 { + all = all[:*n] + } + + cmd := exec.Command(args[0], args[1:]...) + cmd.Dir = *root + in, _ := cmd.StdinPipe() + outp, _ := cmd.StdoutPipe() + errf, _ := os.Create(filepath.Join(*outDir, *tag+".stderr")) + cmd.Stderr = errf + t0 := time.Now() + if err := cmd.Start(); err != nil { + panic(err) + } + defer func() { cmd.Process.Kill(); cmd.Wait() }() + c := &client{in: in, out: bufio.NewReaderSize(outp, 1<<20), ready: make(chan struct{})} + go c.loop() + peak := atomic.Int64{} + stop := make(chan struct{}) + go func() { + for { + select { + case <-stop: + return + case <-time.After(200 * time.Millisecond): + if v := int64(rssKiB(cmd.Process.Pid)); v > peak.Load() { + peak.Store(v) + } + } + } + }() + rootURI := "file://" + *root + _, err := c.request("initialize", map[string]any{"processId": os.Getpid(), "rootUri": rootURI, + "workspaceFolders": []any{map[string]any{"uri": rootURI, "name": filepath.Base(*root)}}, + "capabilities": map[string]any{"window": map[string]any{"workDoneProgress": true}, + "textDocument": map[string]any{"definition": map[string]any{"linkSupport": false}}}, + "initializationOptions": map[string]any{"extendedClientCapabilities": map[string]any{"classFileContentsSupport": true, "progressReportProvider": true}}}, + 10*time.Minute) + if err != nil { + panic(err) + } + c.notify("initialized", map[string]any{}) + ready := time.Since(t0) + if *jdtls { + select { + case <-c.ready: + case <-time.After(20 * time.Minute): + fmt.Fprintln(os.Stderr, "jdtls never ServiceReady") + } + // wait for background builds to settle: no messages for 20s + for time.Since(time.Unix(0, c.lastMsg.Load())) < 20*time.Second { + time.Sleep(time.Second) + } + ready = time.Since(t0) + } + rssReady := rssKiB(cmd.Process.Pid) + + type res struct { + Kind, Name, Text, File string + Line, Col int + Target string + Ms float64 + } + var results []res + var first time.Duration + var rssFirst, fpFirst int + for i, s := range all { + st := time.Now() + raw, err := c.request("textDocument/definition", map[string]any{"textDocument": map[string]any{"uri": s.uri}, + "position": map[string]any{"line": s.line, "character": s.col}}, 60*time.Second) + d := time.Since(st) + if i == 0 { + first = d + rssFirst = rssKiB(cmd.Process.Pid) + fpFirst = footprintKiB(cmd.Process.Pid) + } + target := "" + if err != nil { + target = "ERROR " + err.Error() + } else { + var locs []location + if json.Unmarshal(raw, &locs) != nil { + var one location + if json.Unmarshal(raw, &one) == nil && (one.URI != "" || one.TargetURI != "") { + locs = []location{one} + } + } + if len(locs) > 0 { + u := locs[0].URI + if u == "" { + u = locs[0].TargetURI + } + target = u + ":" + strconv.Itoa(locs[0].Range.Start.Line+1) + } + } + results = append(results, res{s.kind, s.name, s.text, s.file, s.line + 1, s.col, target, float64(d.Microseconds()) / 1000}) + } + rssQueries := rssKiB(cmd.Process.Pid) + // references on hits that land in the project + var refMs []float64 + for _, x := range results { + if len(refMs) >= *refs { + break + } + if !strings.HasPrefix(x.Target, rootURI) || x.Kind != "方法调用 a.b()" { + continue + } + st := time.Now() + c.request("textDocument/references", map[string]any{"textDocument": map[string]any{"uri": "file://" + x.File}, + "position": map[string]any{"line": x.Line - 1, "character": x.Col}, "context": map[string]any{"includeDeclaration": true}}, 120*time.Second) + refMs = append(refMs, float64(time.Since(st).Microseconds())/1000) + } + close(stop) + rssEnd := rssKiB(cmd.Process.Pid) + fpEnd := footprintKiB(cmd.Process.Pid) + + type agg struct{ Total, Hit, Project, JDK, Other int } + byKind := map[string]*agg{} + var lat []float64 + for _, x := range results { + a := byKind[x.Kind] + if a == nil { + a = &agg{} + byKind[x.Kind] = a + } + a.Total++ + lat = append(lat, x.Ms) + if x.Target != "" && !strings.HasPrefix(x.Target, "ERROR") { + a.Hit++ + switch { + case strings.HasPrefix(x.Target, rootURI): + a.Project++ + case strings.Contains(x.Target, "jdk") || strings.Contains(x.Target, "java.base") || strings.Contains(x.Target, "/java/"): + a.JDK++ + default: + a.Other++ + } + } + } + sort.Float64s(lat) + sort.Float64s(refMs) + pct := func(v []float64, p float64) float64 { + if len(v) == 0 { + return 0 + } + return v[int(float64(len(v)-1)*p)] + } + summary := map[string]any{"tag": *tag, "samples": len(results), "byKind": byKind, + "readyMs": ready.Milliseconds(), "firstMs": first.Milliseconds(), + "p50": pct(lat, .5), "p90": pct(lat, .9), "p99": pct(lat, .99), "max": pct(lat, 1), + "refP50": pct(refMs, .5), "refMax": pct(refMs, 1), "refN": len(refMs), + "rssReadyMiB": rssReady / 1024, "rssAfterQueriesMiB": rssQueries / 1024, "rssEndMiB": rssEnd / 1024, "peakMiB": peak.Load() / 1024, + "rssFirstMiB": rssFirst / 1024, "footprintFirstMiB": fpFirst / 1024, "footprintEndMiB": fpEnd / 1024} + b, _ := json.MarshalIndent(summary, "", " ") + os.WriteFile(filepath.Join(*outDir, *tag+".summary.json"), b, 0o644) + rb, _ := json.MarshalIndent(results, "", " ") + os.WriteFile(filepath.Join(*outDir, *tag+".results.json"), rb, 0o644) + fmt.Println(string(b)) +} diff --git a/docs/releases/v0.0.1.md b/docs/releases/v0.0.1.md index ecdfc7e..e6ff80c 100644 --- a/docs/releases/v0.0.1.md +++ b/docs/releases/v0.0.1.md @@ -4,16 +4,22 @@ - 跳转到定义:类型、方法、构造器、字段、枚举常量、局部变量、参数、类型参数、注解及其属性;按导入、同包、通配导入、嵌套、继承链、参数个数和实参类型解析,支持调用链和 `Foo.bar()`。 - Lombok:访问器跳到字段,`@Builder`、`@Slf4j` 和构造器注解生成的成员跳到注解。 - 跳进本机 JDK 的 `src.zip`,并能在 JDK 源码里继续跳;JDK 源码以只读副本打开,放在插件状态目录里。 +- 跳进 Maven 依赖库:从工作区的 `pom.xml`、父 POM、BOM 和本地仓库的 POM 按 Maven 的规则找出依赖 jar(不运行 Maven、不联网), + 有 `-sources.jar` 打开源码,没有就打开从 `.class` 生成的签名存根;每个类第一次用到时才读取,副本同样只读、放在插件状态目录里。 +- 生成代码(`target/generated-sources`、Gradle 的 `build/generated`)也进索引。 +- 字符串字面量、类字面量、`Iface.super` 和 Lombok `log` 字段后面的调用能继续跳。 - 查找引用:只列出能跳回该声明的出现。 - 文档结构(大纲)。 -- 在 Nacos 上和 JDT LS 对比 4500 个位置,两边都有结果时目标一致(两处差异来自 JDK 版本和 JDK 自带的 `org.xml.sax`)。 +- 在 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)。 - 打开的文件按编辑器里未保存的内容索引。 平台:macOS(Apple Silicon、Intel)、Windows(x64、ARM64),每个平台一个 ZIP。 权限:`languageServer.launch`。 -限制:不读依赖库(Spring、MyBatis、Lombok、Swagger 等的声明找不到);没有 lambda 参数和泛型替换的类型推断;不要与 Java Support(JDT LS)同时启用。 +限制:Gradle 项目的依赖库还读不了,本地仓库没有的 jar 跳不进去;没有 lambda 参数和泛型替换的类型推断;不要与 Java Support(JDT LS)同时启用。 +`--no-dependencies` 关闭依赖库跳转。 兼容性: - 只能装进新版 Lithe 桌面端(`desktop-v*` 预发布),旧版 macOS Lithe 无法安装。 diff --git a/internal/codenav/UPSTREAM.md b/internal/codenav/UPSTREAM.md index 44fbc8b..6012d38 100644 --- a/internal/codenav/UPSTREAM.md +++ b/internal/codenav/UPSTREAM.md @@ -24,6 +24,17 @@ 参数个数相同的候选交给 `bestOverload`;`resolveAt` 和 `typeOfExpr` 的限定符用 `qualifierDot` 找, 跳过显式类型实参;限定名注解在 `resolveAt` 里按嵌套类型解析(`annotationName`);`findLocal` 认出 `String... keys` 这样的可变参数参数(原来类型在 `...` 前面时找不到)。 + - 依赖库跳转(见 `internal/deps`): + - `library.go`:`Library` 也装依赖 jar 的源码或签名存根,库路径可以是 `!a/b/C.java` + (`LibraryFQN` 认这种形式);同一个类出现在多个路径时取第一个(JDK 在前,依赖按 classpath 顺序); + `paths` 的键改成类名的斜杠形式,并且是路径的子串,去掉了 `srcs` 表,15 万个类时少占约 10 MiB。 + - `doc.go`:说明里加上依赖 jar。 + - 内存:`lex.go` 加 `lexInto`,`parse.go` 加复用分词缓冲的 `FileParser` 和 `parseTokens`; + 建索引分配的内存从 198 MB 降到 86 MB(Nacos)。 + - 类型推断:`resolve.go` 的 `typeOfExpr` 认出字符串字面量和文本块(`"a".trim()`)、类字面量 + (`Foo.class.getName()`)和 `Iface.super.m()`;`parse.go` 记下 Lombok 日志注解生成的 logger 类型 + (`Type.LogType`,`@Slf4j` 是 `org.slf4j.Logger`),`findField` 给 `log` 字段带上这个类型, + `log.info(...)` 才能跳进日志库。 - 同步后运行 `go test ./...`。 `internal/jdk/find.go` 改编自 Viff 的 `internal/platform/jdk.go`(去掉了 Viff 的进程辅助函数, diff --git a/internal/codenav/doc.go b/internal/codenav/doc.go index 3a21b98..e09f0dc 100644 --- a/internal/codenav/doc.go +++ b/internal/codenav/doc.go @@ -10,8 +10,9 @@ // inference across expressions it cannot see (lambda parameters, generic // substitution) or overload resolution by argument type; such names yield no // answer rather than a guess. Names the working tree does not declare are -// looked up in a Library, the JDK's sources, parsed lazily one class at a -// time; library jars are not read. Usages are the occurrences in the working +// looked up in a Library: the JDK's sources and the project's dependency +// jars (their sources, or stubs of their class files), parsed lazily one +// class at a time. Usages are the occurrences in the working // tree that navigate back to a declaration under those same rules. ADR 0040 // records the trade-off and how it was measured against JDTLS; ADR 0041 adds // the JDK. diff --git a/internal/codenav/lex.go b/internal/codenav/lex.go index e7ce03d..1761c0f 100644 --- a/internal/codenav/lex.go +++ b/internal/codenav/lex.go @@ -42,7 +42,13 @@ var javaKeywords = func() map[string]bool { }() func lex(src []byte) []token { - toks := make([]token, 0, len(src)/5) + return lexInto(make([]token, 0, len(src)/5), src) +} + +// lexInto appends the tokens of src to toks, which a caller parsing many +// files reuses: the tokens of a file are scratch once its declarations are +// read, and allocating them anew was half of what indexing allocated. +func lexInto(toks []token, src []byte) []token { line := int32(0) i, n := 0, len(src) for i < n { diff --git a/internal/codenav/library.go b/internal/codenav/library.go index a35ca65..de2226d 100644 --- a/internal/codenav/library.go +++ b/internal/codenav/library.go @@ -7,7 +7,8 @@ import ( ) // Library is a read-only set of Java sources outside the working tree: the -// JDK's src.zip. Unlike the Index it is not parsed up front. A lookup the +// JDK's src.zip and the project's dependency jars (their sources, or stubs +// written from their class files). Unlike the Index it is not parsed up front. A lookup the // working tree cannot answer asks the library for the type's fully qualified // name, and the source declaring it is read and parsed the first time, then // kept, so a session only ever holds the JDK classes it navigated through. @@ -19,10 +20,11 @@ import ( // // A Library is safe for concurrent use and can be shared by several Indexes. type Library struct { - // paths maps the FQN of each top-level type to the library path of its - // source; it never changes, so misses need no lock. + // paths maps each top-level type to the library path of its source, + // keyed by the type's slash-separated name ("java/lang/String"), which is + // a substring of the path, so a library of 150,000 classes holds one + // string per class; it never changes, so misses need no lock. paths map[string]string - srcs map[string]bool // the values of paths read func(path string) ([]byte, error) mu sync.RWMutex @@ -33,20 +35,22 @@ type Library struct { // NewLibrary returns a library over the sources at paths; the FQN of each // one's top-level type follows from its path (see LibraryFQN), and paths that -// declare no type are ignored. read returns a source's content; it is called -// at most once per path. +// declare no type are ignored. When several paths declare the same type the +// first one wins, so callers list the JDK before the dependencies and those +// in classpath order. read returns a source's content; it is called at most +// once per path. func NewLibrary(paths []string, read func(path string) ([]byte, error)) *Library { l := &Library{ paths: make(map[string]string, len(paths)), - srcs: make(map[string]bool, len(paths)), read: read, files: map[string]*File{}, byFQN: map[string][]*Type{}, } for _, p := range paths { - if fqn, ok := LibraryFQN(p); ok { - l.paths[fqn] = p - l.srcs[p] = true + if key, ok := libraryKey(p); ok { + if _, dup := l.paths[key]; !dup { + l.paths[key] = p + } } } return l @@ -55,13 +59,25 @@ func NewLibrary(paths []string, read func(path string) ([]byte, error)) *Library // LibraryFQN derives the fully qualified name of the top-level type a source // archive entry declares: "java.base/java/lang/String.java" (JDK 9 and later, // led by a module directory) and "java/lang/String.java" (JDK 8) both give -// "java.lang.String". module-info and package-info declare no type. +// "java.lang.String". An entry of a dependency jar is led by the jar's key and +// a "!" ("!a/b/C.java"). module-info and package-info declare no type. func LibraryFQN(entry string) (string, bool) { + key, ok := libraryKey(entry) + if !ok { + return "", false + } + return strings.ReplaceAll(key, "/", "."), true +} + +// libraryKey is LibraryFQN with slashes for dots, as a substring of entry. +func libraryKey(entry string) (string, bool) { name, ok := strings.CutSuffix(entry, ".java") if !ok || name == "" { return "", false } - if i := strings.IndexByte(name, '/'); i > 0 && strings.Contains(name[:i], ".") { + if i := strings.IndexByte(name, '!'); i >= 0 { + name = name[i+1:] + } else if i := strings.IndexByte(name, '/'); i > 0 && strings.Contains(name[:i], ".") { // A module directory: package directories never contain a dot. name = name[i+1:] } @@ -69,7 +85,7 @@ func LibraryFQN(entry string) (string, bool) { if base == "module-info" || base == "package-info" || strings.Contains(name, ".") { return "", false } - return strings.ReplaceAll(name, "/", "."), true + return name, true } // Len is the number of sources parsed so far. @@ -95,7 +111,7 @@ func (l *Library) Err() error { func (l *Library) lookup(fqn string) *Type { src := "" for top := fqn; ; { - if p, ok := l.paths[top]; ok { + if p, ok := l.paths[strings.ReplaceAll(top, ".", "/")]; ok { src = p break } @@ -156,7 +172,7 @@ func (l *Library) file(path string) *File { if ok { return f } - if !l.srcs[path] { + if key, ok := libraryKey(path); !ok || l.paths[key] != path { return nil } l.load(path) diff --git a/internal/codenav/library_test.go b/internal/codenav/library_test.go index 7600bbf..7a6c025 100644 --- a/internal/codenav/library_test.go +++ b/internal/codenav/library_test.go @@ -358,3 +358,96 @@ func TestLibraryConcurrentLookups(t *testing.T) { seen[p] = true } } + +// A JDK and two dependency jars, the way the workspace lists them: JDK entry +// names first, then "!" in classpath order. +var withDeps = map[string]string{ + "java.base/java/lang/Object.java": jdk["java.base/java/lang/Object.java"], + "java.base/java/lang/String.java": jdk["java.base/java/lang/String.java"], + "java.base/java/lang/Class.java": `package java.lang; + +public final class Class { + public String getName() { return null; } +} +`, + "0!org/slf4j/Logger.java": `package org.slf4j; + +public interface Logger { + void info(String msg); +} +`, + // A jar that bundles a copy of a JDK class and of a class an earlier + // jar declares: neither copy is used. + "1!java/lang/String.java": "package java.lang;\n\npublic class String {\n}\n", + "1!org/slf4j/Logger.java": "package org.slf4j;\n\npublic interface Logger {\n}\n", + "1!org/other/Helper.java": "package org.other;\n\npublic class Helper {\n}\n", +} + +func TestLibraryPathsOfDependencyJars(t *testing.T) { + if fqn, ok := LibraryFQN("12!org/slf4j/Logger.java"); !ok || fqn != "org.slf4j.Logger" { + t.Errorf("LibraryFQN of a jar entry = %q %v", fqn, ok) + } + if _, ok := LibraryFQN("12!org/slf4j/package-info.java"); ok { + t.Errorf("a jar's package-info declares a type") + } +} + +// Qualifiers whose type a literal, a class literal, Iface.super or a Lombok +// log annotation gives continue into the library; the first path that +// declares a type wins. +func TestLiteralQualifiersAndLoggers(t *testing.T) { + var paths []string + for p := range withDeps { + paths = append(paths, p) + } + sort.Slice(paths, func(a, b int) bool { + // JDK entries first, then the jars in id order. + da, db := strings.Contains(paths[a], "!"), strings.Contains(paths[b], "!") + if da != db { + return !da + } + return paths[a] < paths[b] + }) + ix := NewIndex() + ix.SetLibrary(NewLibrary(paths, func(p string) ([]byte, error) { return []byte(withDeps[p]), nil })) + const path = "src/com/acme/Literals.java" + src := `package com.acme; + +import lombok.extern.slf4j.Slf4j; + +interface Greeter { + default String greet() { return ""; } +} + +@Slf4j +public class Literals implements Greeter { + public String greet() { + int n = "abc".length(); + String s = String.class.getName(); + log.info("x"); + return Greeter.super.greet(); + } +} +` + ix.Put(ParseFile(path, []byte(src))) + lib := func(p, needle string) Location { + l := declAtSrc(t, p, withDeps[p], needle) + l.Library = true + return l + } + for _, c := range []struct { + click string + want Location + }{ + {"length();", lib("java.base/java/lang/String.java", "length()")}, + {"getName()", lib("java.base/java/lang/Class.java", "getName()")}, + {"info(", lib("0!org/slf4j/Logger.java", "info(")}, + {"greet();\n }\n}", declAtSrc(t, path, src, "greet() { return")}, + } { + got, ok := ix.Resolve(path, []byte(src), offsetOf(t, src, c.click, 0, 0)) + got.rule, got.Name = "", "" + if !ok || got != c.want { + t.Errorf("%q resolved to %+v ok=%v, want %+v", c.click, got, ok, c.want) + } + } +} diff --git a/internal/codenav/parse.go b/internal/codenav/parse.go index e50f0bb..4f44649 100644 --- a/internal/codenav/parse.go +++ b/internal/codenav/parse.go @@ -57,6 +57,10 @@ type Type struct { // Lombok-generated members have no source of their own, so navigation // lands on the annotation, as JDTLS does. BuilderAt, LogAt pos + // LogType is the type of the logger a Lombok log annotation generates + // (org.slf4j.Logger for @Slf4j), so log.info(...) resolves in the + // logging library. + LogType string // Lombok constructor annotations: no-args, all-args, required-args (@Data/@Value too). NoArgsAt, AllArgsAt, ReqArgsAt pos } @@ -227,9 +231,23 @@ func (p *parser) skipAnnotation(i int) (int, string) { type annos struct { lombok uint8 builder, logAt pos + logType string noArgs, allArgs, reqArgs pos } +// lombokLoggers is the logger type each Lombok log annotation generates; +// @CustomLog's is configured in lombok.config, so it has none. +var lombokLoggers = map[string]string{ + "Slf4j": "org.slf4j.Logger", + "XSlf4j": "org.slf4j.ext.XLogger", + "Log4j2": "org.apache.logging.log4j.Logger", + "Log4j": "org.apache.log4j.Logger", + "Log": "java.util.logging.Logger", + "CommonsLog": "org.apache.commons.logging.Log", + "JBossLog": "org.jboss.logging.Logger", + "Flogger": "com.google.common.flogger.FluentLogger", +} + func (p *parser) skipModifiers(i int) (int, annos) { an := annos{builder: noPos, logAt: noPos, noArgs: noPos, allArgs: noPos, reqArgs: noPos} for i < len(p.toks) { @@ -244,8 +262,9 @@ func (p *parser) skipModifiers(i int) (int, annos) { switch name { case "Builder", "SuperBuilder": an.builder = pos{at.Line, p.col(at)} - case "Slf4j", "Log4j2", "Log4j", "Log", "CommonsLog", "XSlf4j", "JBossLog", "Flogger": + case "Slf4j", "Log4j2", "Log4j", "Log", "CommonsLog", "XSlf4j", "JBossLog", "Flogger", "CustomLog": an.logAt = pos{at.Line, p.col(at)} + an.logType = lombokLoggers[name] case "NoArgsConstructor": an.noArgs = pos{at.Line, p.col(at)} case "AllArgsConstructor": @@ -312,8 +331,24 @@ func ParseFile(path string, src []byte) *File { return parseJava(src, path, newInterner(), false).f } +// FileParser is ParseFile for a goroutine that parses many files in a row: +// it reuses the token buffer from one file to the next. The files it returns +// share nothing with the buffer or with src. A FileParser is not safe for +// concurrent use; give each goroutine its own. +type FileParser struct{ toks []token } + +// Parse is ParseFile. +func (fp *FileParser) Parse(path string, src []byte) *File { + fp.toks = lexInto(fp.toks[:0], src) + return parseTokens(src, fp.toks, path, newInterner(), false).f +} + func parseJava(src []byte, rel string, in *interner, keep bool) *parsed { - p := &parser{src: src, toks: lex(src), in: in, keep: keep} + return parseTokens(src, lex(src), rel, in, keep) +} + +func parseTokens(src []byte, toks []token, rel string, in *interner, keep bool) *parsed { + p := &parser{src: src, toks: toks, in: in, keep: keep} p.f = &File{Path: rel, Imports: map[string]string{}} i := 0 for i < len(p.toks) { @@ -616,7 +651,7 @@ func (p *parser) parseTypeDecl(i int, outer *Type, an annos) int { nameTok := i + 1 t := &Type{Name: p.in.s([]byte(p.txt(nameTok))), File: p.f, Outer: outer, Line: p.toks[nameTok].Line, Col: p.col(p.toks[nameTok]), Lombok: an.lombok, - BuilderAt: an.builder, LogAt: an.logAt, NoArgsAt: an.noArgs, AllArgsAt: an.allArgs, ReqArgsAt: an.reqArgs} + BuilderAt: an.builder, LogAt: an.logAt, LogType: an.logType, NoArgsAt: an.noArgs, AllArgsAt: an.allArgs, ReqArgsAt: an.reqArgs} switch kw { case "class": t.Kind = kClass diff --git a/internal/codenav/resolve.go b/internal/codenav/resolve.go index 53b53bf..13d412d 100644 --- a/internal/codenav/resolve.go +++ b/internal/codenav/resolve.go @@ -723,7 +723,7 @@ func (q *qctx) findField(t *Type, name string) *Member { if name == "log" { for _, ht := range h { if ht.LogAt.Line >= 0 { - return &Member{Name: name, Kind: kField, Owner: ht, Line: ht.LogAt.Line, Col: ht.LogAt.Col} + return &Member{Name: name, Kind: kField, Type: ht.LogType, Owner: ht, Line: ht.LogAt.Line, Col: ht.LogAt.Col} } } } @@ -873,11 +873,25 @@ func (q *qctx) typeOfExpr(end int, ctx *Type) *Type { q.depth++ defer func() { q.depth-- }() switch { + case p.toks[end].Kind == tString: // "text".trim(), and text blocks + return q.impliedType("java.lang.String") case p.ident(end): s := p.txt(end) if p.is(end-1, ".") { - if s == "this" { // Outer.this + switch s { + case "this": // Outer.this return q.resolveSimple(p.txt(end-2), q.file, ctx) + case "class": // Foo.class.getName() + return q.impliedType("java.lang.Class") + case "super": // Iface.super.m(): the interface; Outer.super.m(): Outer's superclass + t := q.resolveSimple(p.txt(end-2), q.file, ctx) + if t == nil || t.Kind == kInterface { + return t + } + if ss := q.superTypes(t); len(ss) > 0 { + return ss[0] + } + return nil } ot := q.typeOfExpr(end-2, ctx) if ot == nil { diff --git a/internal/deps/classfile.go b/internal/deps/classfile.go new file mode 100644 index 0000000..8c4985b --- /dev/null +++ b/internal/deps/classfile.go @@ -0,0 +1,543 @@ +package deps + +import ( + "encoding/binary" + "errors" + "fmt" + "math" + "strconv" + "strings" + "unicode/utf16" +) + +// A reader of the parts of a class file (JVMS chapter 4) that a signature +// stub needs: the class's name, flags, supertypes and generic signature, its +// fields and methods with their descriptors, signatures, parameter names, +// constant values and annotation defaults, its member classes and its record +// components. Method bodies and every other attribute are skipped. + +// Access flags (JVMS 4.1, 4.5, 4.6, 4.7.6). +const ( + accPublic = 0x0001 + accPrivate = 0x0002 + accProtected = 0x0004 + accStatic = 0x0008 + accFinal = 0x0010 + accBridge = 0x0040 + accVarargs = 0x0080 + accNative = 0x0100 + accInterface = 0x0200 + accAbstract = 0x0400 + accSynthetic = 0x1000 + accAnnotation = 0x2000 + accEnum = 0x4000 +) + +var errClassFormat = errors.New("not a valid class file") + +// classFile is what a stub is written from. +type classFile struct { + access uint16 + name string // internal binary name: "a/b/Outer$Inner" + super string // "" for java/lang/Object and module-info + interfaces []string + signature string // the Signature attribute, "" when absent + fields []member + methods []member + inner []innerClass + record []member // record components: name, desc, signature +} + +type member struct { + access uint16 + name string + desc string + signature string + params []string // parameter names when the class file records them + constant string // a field's ConstantValue as a Java literal, "" if none + dflt string // an annotation element's default as Java source, "" if none +} + +// innerClass is one entry of the InnerClasses attribute. +type innerClass struct { + inner, outer, name string // outer and name are "" for local and anonymous classes + access uint16 +} + +type cpEntry struct { + tag byte + a, b uint16 // indexes, or the two halves of a 4-byte value + s string // Utf8 + v uint64 // Integer, Float, Long, Double bits +} + +type reader struct { + b []byte + off int + err error +} + +func (r *reader) need(n int) bool { + if r.err != nil { + return false + } + if n < 0 || r.off+n > len(r.b) { + r.err = errClassFormat + return false + } + return true +} + +func (r *reader) u1() byte { + if !r.need(1) { + return 0 + } + v := r.b[r.off] + r.off++ + return v +} + +func (r *reader) u2() uint16 { + if !r.need(2) { + return 0 + } + v := binary.BigEndian.Uint16(r.b[r.off:]) + r.off += 2 + return v +} + +func (r *reader) u4() uint32 { + if !r.need(4) { + return 0 + } + v := binary.BigEndian.Uint32(r.b[r.off:]) + r.off += 4 + return v +} + +func (r *reader) bytes(n int) []byte { + if !r.need(n) { + return nil + } + v := r.b[r.off : r.off+n] + r.off += n + return v +} + +// parseClass reads b, the content of a .class file. +func parseClass(b []byte) (*classFile, error) { + r := &reader{b: b} + if r.u4() != 0xCAFEBABE { + return nil, errClassFormat + } + r.u2() // minor + r.u2() // major + n := int(r.u2()) + cp := make([]cpEntry, n) + for i := 1; i < n && r.err == nil; i++ { + e := cpEntry{tag: r.u1()} + switch e.tag { + case 1: + e.s = decodeModifiedUTF8(r.bytes(int(r.u2()))) + case 3, 4: + e.v = uint64(r.u4()) + case 5, 6: + e.v = uint64(r.u4())<<32 | uint64(r.u4()) + case 7, 8, 16, 19, 20: + e.a = r.u2() + case 9, 10, 11, 12, 17, 18: + e.a, e.b = r.u2(), r.u2() + case 15: + r.u1() + e.a = r.u2() + default: + return nil, fmt.Errorf("%w: constant pool tag %d", errClassFormat, e.tag) + } + cp[i] = e + if e.tag == 5 || e.tag == 6 { + i++ // eight-byte constants take two entries + } + } + c := &classFile{} + p := pool(cp) + c.access = r.u2() + c.name = p.class(r.u2()) + c.super = p.class(r.u2()) + for k := int(r.u2()); k > 0 && r.err == nil; k-- { + c.interfaces = append(c.interfaces, p.class(r.u2())) + } + for k := int(r.u2()); k > 0 && r.err == nil; k-- { + c.fields = append(c.fields, readMember(r, p, false)) + } + for k := int(r.u2()); k > 0 && r.err == nil; k-- { + c.methods = append(c.methods, readMember(r, p, true)) + } + for k := int(r.u2()); k > 0 && r.err == nil; k-- { + name := p.utf8(r.u2()) + body := &reader{b: r.bytes(int(r.u4()))} + switch name { + case "Signature": + c.signature = p.utf8(body.u2()) + case "InnerClasses": + for m := int(body.u2()); m > 0 && body.err == nil; m-- { + ic := innerClass{inner: p.class(body.u2()), outer: p.class(body.u2()), name: p.utf8(body.u2())} + ic.access = body.u2() + c.inner = append(c.inner, ic) + } + case "Record": + for m := int(body.u2()); m > 0 && body.err == nil; m-- { + rc := member{name: p.utf8(body.u2()), desc: p.utf8(body.u2())} + for a := int(body.u2()); a > 0 && body.err == nil; a-- { + an := p.utf8(body.u2()) + ab := &reader{b: body.bytes(int(body.u4()))} + if an == "Signature" { + rc.signature = p.utf8(ab.u2()) + } + } + c.record = append(c.record, rc) + } + } + if body.err != nil { + return nil, body.err + } + } + if r.err != nil { + return nil, r.err + } + if c.name == "" { + return nil, errClassFormat + } + return c, nil +} + +func readMember(r *reader, p pool, method bool) member { + m := member{access: r.u2(), name: p.utf8(r.u2()), desc: p.utf8(r.u2())} + var mp, lvt []string + for k := int(r.u2()); k > 0 && r.err == nil; k-- { + name := p.utf8(r.u2()) + body := &reader{b: r.bytes(int(r.u4()))} + switch name { + case "Signature": + m.signature = p.utf8(body.u2()) + case "ConstantValue": + if !method { + m.constant = p.literal(body.u2(), m.desc) + } + case "MethodParameters": + for n := int(body.u1()); n > 0 && body.err == nil; n-- { + mp = append(mp, p.utf8(body.u2())) + body.u2() + } + case "AnnotationDefault": + m.dflt = elementValue(body, p, 0) + if body.err != nil { + m.dflt = "" + } + case "Code": + lvt = paramsFromCode(body, p, m) + } + } + if method { + m.params = paramNames(mp, lvt, m) + } + return m +} + +// paramsFromCode reads the LocalVariableTable of a Code attribute: classes +// compiled with debug information (Maven's default) name their parameters +// there. It returns the names by local variable slot, "" for unknown slots. +func paramsFromCode(r *reader, p pool, m member) []string { + r.u2() // max_stack + r.u2() // max_locals + r.bytes(int(r.u4())) + r.bytes(int(r.u2()) * 8) // exception table + var slots []string + for k := int(r.u2()); k > 0 && r.err == nil; k-- { + name := p.utf8(r.u2()) + body := &reader{b: r.bytes(int(r.u4()))} + if name != "LocalVariableTable" { + continue + } + for n := int(body.u2()); n > 0 && body.err == nil; n-- { + start := body.u2() + body.u2() // length + vn := p.utf8(body.u2()) + body.u2() // descriptor + slot := int(body.u2()) + if start != 0 || slot > 255 { + continue + } + for len(slots) <= slot { + slots = append(slots, "") + } + slots[slot] = vn + } + } + return slots +} + +// paramNames picks the parameter names of m: the MethodParameters attribute +// when it names every parameter, else the local variable slots of the +// parameters, else none. +func paramNames(mp, slots []string, m member) []string { + params, _, err := parseMethodDescriptor(m.desc) + if err != nil { + return nil + } + if len(mp) == len(params) { + ok := true + for _, n := range mp { + if !validIdent(n) { + ok = false + } + } + if ok { + return mp + } + } + if len(slots) == 0 { + return nil + } + slot := 0 + if m.access&accStatic == 0 { + slot = 1 // this + } + out := make([]string, len(params)) + for i, t := range params { + if slot >= len(slots) || !validIdent(slots[slot]) { + return nil + } + out[i] = slots[slot] + slot++ + if t.dims == 0 && (t.prim == "long" || t.prim == "double") { + slot++ + } + } + return out +} + +func validIdent(s string) bool { + if s == "" || javaKeyword[s] { + return false + } + for i, c := range s { + ok := c == '_' || c == '$' || c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c > 0x7f || i > 0 && c >= '0' && c <= '9' + if !ok { + return false + } + } + return true +} + +var javaKeyword = func() map[string]bool { + m := map[string]bool{} + for _, w := range strings.Fields(`abstract assert boolean break byte case catch char class const continue + default do double else enum extends final finally float for goto if implements import instanceof int + interface long native new package private protected public return short static strictfp super switch + synchronized this throw throws transient try void volatile while true false null _`) { + m[w] = true + } + return m +}() + +// elementValue renders an annotation element value (JVMS 4.7.16.1) as Java +// source; nested annotations, which a stub does not need, become "". +func elementValue(r *reader, p pool, depth int) string { + if depth > 8 { + r.err = errClassFormat + return "" + } + tag := r.u1() + switch tag { + case 's': // the index names a Utf8 constant, not a String one + return stringLiteral(p.utf8(r.u2())) + case 'B', 'C', 'D', 'F', 'I', 'J', 'S', 'Z': + return p.literal(r.u2(), string(tag)) + case 'e': + t, name := p.utf8(r.u2()), p.utf8(r.u2()) + ty, err := parseFieldDescriptor(t) + if err != nil { + return "" + } + return "\x00" + ty.class + "\x00." + name // the type is rendered by the stub writer + case 'c': + t, err := parseFieldDescriptor(p.utf8(r.u2())) + if err != nil { + return "" + } + if t.class == "" { + return t.prim + strings.Repeat("[]", t.dims) + ".class" + } + return "\x00" + t.class + "\x00" + strings.Repeat("[]", t.dims) + ".class" + case '@': + skipAnnotation(r, p, depth+1) + return "" + case '[': + var parts []string + for k := int(r.u2()); k > 0 && r.err == nil; k-- { + v := elementValue(r, p, depth+1) + if v == "" { + parts = nil + for ; k > 1 && r.err == nil; k-- { + elementValue(r, p, depth+1) + } + return "" + } + parts = append(parts, v) + } + return "{" + strings.Join(parts, ", ") + "}" + } + r.err = errClassFormat + return "" +} + +func skipAnnotation(r *reader, p pool, depth int) { + r.u2() + for k := int(r.u2()); k > 0 && r.err == nil; k-- { + r.u2() + elementValue(r, p, depth) + } +} + +type pool []cpEntry + +func (p pool) at(i uint16, tag byte) (cpEntry, bool) { + if int(i) <= 0 || int(i) >= len(p) || p[i].tag != tag { + return cpEntry{}, false + } + return p[i], true +} + +func (p pool) utf8(i uint16) string { + e, _ := p.at(i, 1) + return e.s +} + +func (p pool) class(i uint16) string { + e, ok := p.at(i, 7) + if !ok { + return "" + } + return p.utf8(e.a) +} + +// literal renders constant i as a Java literal of the type desc names. +func (p pool) literal(i uint16, desc string) string { + if int(i) <= 0 || int(i) >= len(p) { + return "" + } + e := p[i] + switch e.tag { + case 3: + v := int32(uint32(e.v)) + switch desc { + case "Z": + return strconv.FormatBool(v != 0) + case "C": + return charLiteral(rune(v)) + case "B": + return "(byte) " + strconv.Itoa(int(v)) + case "S": + return "(short) " + strconv.Itoa(int(v)) + } + return strconv.Itoa(int(v)) + case 4: + return floatLiteral(float64(math.Float32frombits(uint32(e.v))), 32, "F") + case 5: + return strconv.FormatInt(int64(e.v), 10) + "L" + case 6: + return floatLiteral(math.Float64frombits(e.v), 64, "") + case 8: + return stringLiteral(p.utf8(e.a)) + } + return "" +} + +func floatLiteral(f float64, bits int, suffix string) string { + switch { + case math.IsNaN(f): + if bits == 32 { + return "0.0F / 0.0F" + } + return "0.0 / 0.0" + case math.IsInf(f, 1): + return "1.0" + suffix + " / 0.0" + suffix + case math.IsInf(f, -1): + return "-1.0" + suffix + " / 0.0" + suffix + } + s := strconv.FormatFloat(f, 'g', -1, bits) + if !strings.ContainsAny(s, ".eE") { + s += ".0" + } + return s + suffix +} + +func stringLiteral(s string) string { + var b strings.Builder + b.WriteByte('"') + for _, c := range s { + b.WriteString(escapeRune(c, '"')) + } + b.WriteByte('"') + return b.String() +} + +func charLiteral(c rune) string { return "'" + escapeRune(c, '\'') + "'" } + +func escapeRune(c rune, quote rune) string { + switch c { + case '\\': + return `\\` + case '\n': + return `\n` + case '\r': + return `\r` + case '\t': + return `\t` + case '\b': + return `\b` + case '\f': + return `\f` + case quote: + return `\` + string(quote) + } + if c < 0x20 || c == 0x7f || c >= 0xd800 && c < 0xe000 || c == 0x2028 || c == 0x2029 { + return fmt.Sprintf(`\u%04x`, c) + } + return string(c) +} + +// decodeModifiedUTF8 decodes the class file's string encoding (JVMS 4.4.7): +// UTF-8 with NUL as two bytes and supplementary characters as two encoded +// surrogates. +func decodeModifiedUTF8(b []byte) string { + ascii := true + for _, c := range b { + if c >= 0x80 || c == 0 { + ascii = false + break + } + } + if ascii { + return string(b) + } + u := make([]uint16, 0, len(b)) + for i := 0; i < len(b); { + c := b[i] + switch { + case c < 0x80: + u = append(u, uint16(c)) + i++ + case c&0xE0 == 0xC0 && i+1 < len(b): + u = append(u, uint16(c&0x1F)<<6|uint16(b[i+1]&0x3F)) + i += 2 + case c&0xF0 == 0xE0 && i+2 < len(b): + u = append(u, uint16(c&0x0F)<<12|uint16(b[i+1]&0x3F)<<6|uint16(b[i+2]&0x3F)) + i += 3 + default: + u = append(u, 0xFFFD) + i++ + } + } + return string(utf16.Decode(u)) +} diff --git a/internal/deps/deps_test.go b/internal/deps/deps_test.go new file mode 100644 index 0000000..16bae31 --- /dev/null +++ b/internal/deps/deps_test.go @@ -0,0 +1,367 @@ +package deps + +import ( + "archive/zip" + "context" + "crypto/sha256" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "github.com/Lithe-IDEA/lithe-plugin-java-navigation/internal/codenav" +) + +func ctx(t *testing.T) context.Context { + t.Helper() + c, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + return c +} + +// A jar with a sources jar is read from the sources jar. +func TestTheSourcesJarIsReadWhenItWasDownloaded(t *testing.T) { + s := fixtureSet(t) + b, err := s.Read(pathOf(t, s, "com/example/helper/Helper.java")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(b), "return text.toUpperCase();") { + t.Fatalf("Helper source = %s", b) + } +} + +// Stubs parse into the declarations of their class files: member classes, +// enum constants, record components and annotation elements. +func TestStubsParseIntoTheirDeclarations(t *testing.T) { + s := fixtureSet(t) + parse := func(name string) *codenav.File { + p := pathOf(t, s, "com/example/lib/"+name+".java") + b, err := s.Read(p) + if err != nil { + t.Fatal(err) + } + f := codenav.ParseFile(p, b) + if f.Pkg != "com.example.lib" || len(f.Types) != 1 || f.Types[0].Name != name { + t.Fatalf("%s parsed as %+v", name, f) + } + return f + } + names := func(t *codenav.Type) []string { + var out []string + for _, m := range t.Members { + out = append(out, m.Name) + } + for _, n := range t.Nested { + out = append(out, n.Name+"{}") + } + return out + } + for name, want := range map[string]string{ + "Schema": "description requiredMode implementation allowableValues maxLength RequiredMode{}", + "Container": "LIMIT NAME BIG index Container Container get size max entry Builder{} Cursor{}", + "Level": "LOW HIGH weight label", + "Repository": "findById findAll exists identity", + } { + got := strings.Join(names(parse(name).Types[0]), " ") + if got != want { + t.Errorf("%s declares %q, want %q", name, got, want) + } + } + mode := parse("Schema").Types[0].Nested[0] + if got := strings.Join(names(mode), " "); got != "AUTO REQUIRED NOT_REQUIRED" { + t.Errorf("RequiredMode declares %q", got) + } +} + +// A copy is written once under the state directory, read-only, and maps back +// to its library path; the jar is never written. +func TestExtractWritesOneReadOnlyCopyPerClass(t *testing.T) { + jar := filepath.Join("testdata", "fixture", "lib-1.0.jar") + before := fileHash(t, jar) + s := fixtureSet(t) + state := t.TempDir() + p := pathOf(t, s, "com/example/lib/Container.java") + file, err := s.Extract(state, p) + if err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(file, CacheDir(state)+string(filepath.Separator)) || + !strings.HasSuffix(filepath.ToSlash(file), "/com/example/lib/Container.java") { + t.Fatalf("copy at %s", file) + } + b, err := os.ReadFile(file) + if err != nil { + t.Fatal(err) + } + want, _ := s.Read(p) + if string(b) != string(want) { + t.Errorf("copy differs from the stub") + } + if fi, err := os.Stat(file); err != nil || runtime.GOOS != "windows" && fi.Mode().Perm()&0o222 != 0 { + t.Errorf("copy mode = %v, %v", fi.Mode(), err) + } + again, err := s.Extract(state, p) + if err != nil || again != file { + t.Errorf("second Extract = %s, %v", again, err) + } + if back, ok := s.PathOf(state, file); !ok || back != p { + t.Errorf("PathOf(copy) = %s %v, want %s", back, ok, p) + } + if _, ok := s.PathOf(state, filepath.Join(state, "elsewhere", "A.java")); ok { + t.Errorf("PathOf accepted a file outside the copies") + } + if fileHash(t, jar) != before { + t.Errorf("the jar changed") + } +} + +func fileHash(t *testing.T, path string) [32]byte { + t.Helper() + b, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + return sha256.Sum256(b) +} + +// Truncated or corrupted class files are rejected without a panic. +func TestBrokenClassFilesAreRejected(t *testing.T) { + zr, err := zip.OpenReader(filepath.Join("testdata", "fixture", "lib-1.0.jar")) + if err != nil { + t.Fatal(err) + } + defer zr.Close() + for _, f := range zr.File { + if !strings.HasSuffix(f.Name, ".class") { + continue + } + rc, err := f.Open() + if err != nil { + t.Fatal(err) + } + b := make([]byte, f.UncompressedSize64) + if _, err := rc.Read(b); err != nil && len(b) == 0 { + t.Fatal(err) + } + rc.Close() + if _, err := parseClass(b); err != nil { + t.Fatalf("%s: %v", f.Name, err) + } + for n := 0; n < len(b); n += 7 { + if _, err := parseClass(b[:n]); err == nil { + t.Errorf("%s truncated to %d bytes parsed", f.Name, n) + } + } + for i := 10; i < len(b); i += 13 { + c := append([]byte(nil), b...) + c[i] ^= 0xff + parseClass(c) // must not panic + } + } +} + +func TestVersionsOrderLikeMaven(t *testing.T) { + for _, c := range []struct { + a, b string + want int + }{ + {"1.2", "1.10", -1}, + {"1.0", "1", 0}, + {"1.0.0.RELEASE", "1.0", 0}, + {"2.0-SNAPSHOT", "2.0", -1}, + {"2.0-rc1", "2.0", -1}, + {"2.0-beta", "2.0-rc1", -1}, + {"2.0.1", "2.0", 1}, + {"5.8.48", "5.8.9", 1}, + {"1.0.Final", "1.0", 0}, + {"1.0-sp1", "1.0", 1}, + } { + if got := compareVersions(c.a, c.b); got != c.want { + t.Errorf("compareVersions(%s, %s) = %d, want %d", c.a, c.b, got, c.want) + } + if got := compareVersions(c.b, c.a); got != -c.want { + t.Errorf("compareVersions(%s, %s) = %d, want %d", c.b, c.a, got, -c.want) + } + } +} + +// repo is a local Maven repository for a test. +type repo struct { + t *testing.T + root string +} + +func newRepo(t *testing.T) *repo { return &repo{t: t, root: t.TempDir()} } + +// add writes group:artifact:version's POM with the given body (inside +// ) and, unless body is a pom packaging, a jar. +func (r *repo) add(coord, body string, jar bool) string { + r.t.Helper() + parts := strings.Split(coord, ":") + g, a, v := parts[0], parts[1], parts[2] + dir := filepath.Join(r.root, filepath.FromSlash(strings.ReplaceAll(g, ".", "/")), a, v) + if err := os.MkdirAll(dir, 0o755); err != nil { + r.t.Fatal(err) + } + pom := "4.0.0" + g + "" + a + + "" + v + "" + body + "" + if err := os.WriteFile(filepath.Join(dir, a+"-"+v+".pom"), []byte(pom), 0o644); err != nil { + r.t.Fatal(err) + } + if jar { + path := filepath.Join(dir, a+"-"+v+".jar") + writeJar(r.t, path, map[string]string{"META-INF/MANIFEST.MF": "Manifest-Version: 1.0\n"}) + return path + } + return "" +} + +func writeJar(t *testing.T, path string, files map[string]string) { + t.Helper() + f, err := os.Create(path) + if err != nil { + t.Fatal(err) + } + zw := zip.NewWriter(f) + for name, body := range files { + w, err := zw.Create(name) + if err != nil { + t.Fatal(err) + } + w.Write([]byte(body)) + } + if err := zw.Close(); err != nil { + t.Fatal(err) + } + f.Close() +} + +func writeFile(t *testing.T, path, body string) string { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + return path +} + +func dep(coord string, extra ...string) string { + parts := strings.Split(coord, ":") + s := "" + parts[0] + "" + parts[1] + "" + if len(parts) > 2 && parts[2] != "" { + s += "" + parts[2] + "" + } + return s + strings.Join(extra, "") + "" +} + +func coords(jars []Jar) string { + var out []string + for _, j := range jars { + out = append(out, j.Coord) + } + return strings.Join(out, " ") +} + +// Dependencies are mediated as Maven does: the parent's properties and +// management apply, an imported BOM manages versions, nearest wins, test, +// provided and optional dependencies are not transitive, exclusions apply, +// and the working tree's own modules are not jars. +func TestMavenJarsFollowsMavenMediation(t *testing.T) { + r := newRepo(t) + r.add("org.bom:platform:1.0", `pom`+ + dep("org.lib:core:2.0")+dep("org.lib:util:1.5")+``, false) + r.add("org.lib:core:2.0", ``+dep("org.lib:util:1.0")+dep("org.lib:junk:1.0", "test")+ + dep("org.lib:opt:1.0", "true")+dep("org.lib:dropped:1.0")+dep("org.lib:deep:1.0")+``, true) + r.add("org.lib:core:1.0", ``, true) + r.add("org.lib:util:1.0", ``, true) + r.add("org.lib:util:1.5", ``, true) + r.add("org.lib:junk:1.0", ``, true) + r.add("org.lib:opt:1.0", ``, true) + r.add("org.lib:dropped:1.0", ``, true) + r.add("org.lib:deep:1.0", ``+dep("org.lib:util:0.9")+``, true) + r.add("org.lib:servlet:4.0", ``, true) + r.add("org.lib:newest:1.2", ``, true) + r.add("org.lib:newest:1.10", ``, true) + + tree := t.TempDir() + root := writeFile(t, filepath.Join(tree, "pom.xml"), `com.acmeparent +3.0pom +1.0 +`+dep("org.bom:platform:${platform.version}", "pomimport")+` +`) + app := writeFile(t, filepath.Join(tree, "app", "pom.xml"), `com.acmeparent +3.0app`+ + dep("com.acme:model:${project.version}")+ + dep("org.lib:core", "org.libdropped")+ + dep("org.lib:servlet:4.0", "provided")+ + dep("org.lib:newest:${undefined.version}")+ + dep("org.lib:missing:1.0")+``) + model := writeFile(t, filepath.Join(tree, "model", "pom.xml"), `com.acmeparent +3.0model`) + + jars, err := MavenJars(ctx(t), []string{root, app, model}, r.root, t.Logf) + if err != nil { + t.Fatal(err) + } + // core's version comes from the BOM, util from the BOM over core's 1.0 + // (the root's management applies to transitive dependencies), deep's + // own util 0.9 loses to the nearer one; servlet is provided but direct. + want := "org.lib:core:2.0 org.lib:servlet:4.0 org.lib:newest:1.10 org.lib:util:1.5 org.lib:deep:1.0" + if got := coords(jars); got != want { + t.Errorf("jars = %s\nwant %s", got, want) + } + for _, j := range jars { + if _, err := os.Stat(j.Path); err != nil || j.Sources != "" { + t.Errorf("%s: path %s sources %q", j.Coord, j.Path, j.Sources) + } + } +} + +// A POM in ISO-8859-1, as many Apache POMs are, is read. +func TestLatin1POMsAreRead(t *testing.T) { + r := newRepo(t) + r.add("org.lib:a:1.0", "", true) + pom := "\ncom.acmecaf\xe9" + + "1caf\xe9" + dep("org.lib:a:1.0") + "" + tree := t.TempDir() + path := writeFile(t, filepath.Join(tree, "pom.xml"), pom) + jars, err := MavenJars(ctx(t), []string{path}, r.root, t.Logf) + if err != nil || coords(jars) != "org.lib:a:1.0" { + t.Fatalf("jars = %v, %v", coords(jars), err) + } +} + +// A sources jar next to the jar is used. +func TestSourcesJarsAreFoundNextToTheJar(t *testing.T) { + r := newRepo(t) + jar := r.add("org.lib:a:1.0", "", true) + writeJar(t, strings.TrimSuffix(jar, ".jar")+"-sources.jar", map[string]string{"org/lib/A.java": "package org.lib; public class A {}"}) + tree := t.TempDir() + path := writeFile(t, filepath.Join(tree, "pom.xml"), `xy1`+ + dep("org.lib:a:1.0")+``) + jars, err := MavenJars(ctx(t), []string{path}, r.root, t.Logf) + if err != nil || len(jars) != 1 || !strings.HasSuffix(jars[0].Sources, "a-1.0-sources.jar") { + t.Fatalf("jars = %+v, %v", jars, err) + } + s := Open(jars, t.Logf) + if s.Classes() != 1 || !strings.HasSuffix(s.Paths()[0], Sep+"org/lib/A.java") { + t.Errorf("paths = %v", s.Paths()) + } +} + +func TestLocalRepositoryHonoursSettings(t *testing.T) { + home := t.TempDir() + if got := LocalRepository(home); got != filepath.Join(home, ".m2", "repository") { + t.Errorf("default = %s", got) + } + custom := filepath.Join(home, "repo") + writeFile(t, filepath.Join(home, ".m2", "settings.xml"), `${user.home}/repo`) + if got := LocalRepository(home); filepath.Clean(got) != custom { + t.Errorf("from settings = %s, want %s", got, custom) + } +} diff --git a/internal/deps/jars.go b/internal/deps/jars.go new file mode 100644 index 0000000..9f956cb --- /dev/null +++ b/internal/deps/jars.go @@ -0,0 +1,354 @@ +// 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, +// 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; +// codenav parses one class the first time a lookup needs it, so memory +// grows with the classes a session navigates through, not with the +// classpath. +// +// Navigation opens a declaration as a read-only copy of that source under +// the state directory the host gives the plugin, never in the local +// repository or the plugin package. +package deps + +import ( + "archive/zip" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io" + "io/fs" + "os" + "path" + "path/filepath" + "runtime" + "strconv" + "strings" + "sync" + "time" + + "github.com/Lithe-IDEA/lithe-plugin-java-navigation/internal/codenav" +) + +// MaxEntrySize skips class files and sources too large to read. +const MaxEntrySize = 4 << 20 + +// stubFormat names the output of the stub writer; change it with the writer, +// so copies extracted by an older version are not reused. +const stubFormat = "1" + +// ErrChanged reports a jar that changed since it was listed. +var ErrChanged = errors.New("the dependency jar changed on disk") + +// Sep separates a jar's id from the entry in a library path: +// "!a/b/C.java". Ids are numbers of one Set. +const Sep = "!" + +// Set is the listed classes of a project's dependency jars. It never changes +// after Open, so its methods run on any goroutine. +type Set struct { + jars []*archive + byID map[string]*archive + byDir map[string]*archive + paths []string // library paths of every listed top-level class +} + +// archive is one listed jar. +type archive struct { + Jar + id string // names the jar in library paths: short, as there is one path per class + dir string // names the jar's directory of copies after its coordinates and identity + file string // the archive read: the sources jar when there is one + sources bool + size int64 + mod time.Time +} + +// Open lists the jars, skipping those that cannot be read; an empty Set is +// not an error. logf reports skipped jars. +func Open(jars []Jar, logf func(string, ...any)) *Set { + if logf == nil { + logf = func(string, ...any) {} + } + s := &Set{byID: map[string]*archive{}, byDir: map[string]*archive{}} + listed := make([]*archive, len(jars)) + entries := make([][]string, len(jars)) + errs := make([]error, len(jars)) + next := make(chan int) + var wg sync.WaitGroup + for range min(runtime.GOMAXPROCS(0), 8) { + wg.Add(1) + go func() { + defer wg.Done() + for i := range next { + listed[i], entries[i], errs[i] = listJar(jars[i]) + } + }() + } + for i := range jars { + next <- i + } + close(next) + wg.Wait() + for i, a := range listed { + if errs[i] != nil { + logf("skipping %s: %v", jars[i].Path, errs[i]) + continue + } + if _, dup := s.byDir[a.dir]; dup { + continue + } + a.id = strconv.FormatInt(int64(len(s.jars)), 36) + s.jars = append(s.jars, a) + s.byID[a.id] = a + s.byDir[a.dir] = a + for _, e := range entries[i] { + s.paths = append(s.paths, a.id+Sep+e) + } + } + return s +} + +// Len is the number of listed jars. +func (s *Set) Len() int { return len(s.jars) } + +// Classes is the number of listed top-level classes. +func (s *Set) Classes() int { return len(s.paths) } + +// Paths returns the library path of every listed class, in classpath order: +// the first jar that declares a class is the one a lookup finds. +func (s *Set) Paths() []string { return s.paths } + +// listJar lists the top-level classes of j: the .java entries of its +// sources jar when it has one that opens, else its .class entries, named +// as the .java file a stub of them is. +func listJar(j Jar) (*archive, []string, error) { + a := &archive{Jar: j} + if j.Sources != "" { + a.file, a.sources = j.Sources, true + if entries, err := a.list(); err == nil && len(entries) > 0 { + return a, entries, nil + } + } + a.file, a.sources = j.Path, false + entries, err := a.list() + if err != nil { + return nil, nil, err + } + return a, entries, nil +} + +func (a *archive) list() ([]string, error) { + zr, err := zip.OpenReader(a.file) + if err != nil { + return nil, err + } + defer zr.Close() + fi, err := os.Stat(a.file) + if err != nil { + return nil, err + } + a.size, a.mod = fi.Size(), fi.ModTime() + id := a.file + "\x00" + strconv.FormatInt(a.size, 10) + "\x00" + a.mod.UTC().Format(time.RFC3339Nano) + if !a.sources { + id += "\x00stub " + stubFormat // a new stub writer never reuses older copies + } + h := sha256.Sum256([]byte(id)) + a.dir = sanitize(strings.ReplaceAll(a.Coord, ":", "_")) + "-" + hex.EncodeToString(h[:6]) + var out []string + for _, f := range zr.File { + name := f.Name + if strings.HasPrefix(name, "META-INF/") || f.FileInfo().IsDir() || !validEntry(name) { + continue + } + base := path.Base(name) + if base == "module-info.class" || base == "package-info.class" || base == "module-info.java" || base == "package-info.java" { + continue + } + if a.sources { + if strings.HasSuffix(name, ".java") { + out = append(out, name) + } + continue + } + stem, ok := strings.CutSuffix(name, ".class") + if !ok || strings.Contains(path.Base(stem), "$") { + continue // member, local and anonymous classes are written into their top-level class's stub + } + out = append(out, stem+".java") + } + return out, nil +} + +// Has reports whether p is a library path of this set. +func (s *Set) Has(p string) bool { + a, entry, ok := s.split(p) + return ok && a != nil && entry != "" +} + +func (s *Set) split(p string) (*archive, string, bool) { + id, entry, ok := strings.Cut(p, Sep) + if !ok { + return nil, "", false + } + a := s.byID[id] + if a == nil || !validEntry(entry) || !strings.HasSuffix(entry, ".java") { + return nil, "", false + } + return a, entry, true +} + +// Read returns the source of library path p: the sources jar's entry, or the +// stub of the class and its member classes. +func (s *Set) Read(p string) ([]byte, error) { + a, entry, ok := s.split(p) + if !ok { + return nil, fmt.Errorf("%s: %w", p, fs.ErrNotExist) + } + zr, err := zip.OpenReader(a.file) + if err != nil { + return nil, err + } + defer zr.Close() + if fi, err := os.Stat(a.file); err != nil || fi.Size() != a.size || !fi.ModTime().Equal(a.mod) { + return nil, ErrChanged + } + files := make(map[string]*zip.File, len(zr.File)) + for _, f := range zr.File { + files[f.Name] = f + } + read := func(name string) ([]byte, error) { + f := files[name] + if f == nil { + return nil, fmt.Errorf("%s: %w", name, fs.ErrNotExist) + } + if f.UncompressedSize64 > MaxEntrySize { + return nil, fmt.Errorf("%s: larger than %d bytes", name, MaxEntrySize) + } + rc, err := f.Open() + if err != nil { + return nil, err + } + defer rc.Close() + return io.ReadAll(io.LimitReader(rc, MaxEntrySize+1)) + } + if a.sources { + return read(entry) + } + stem := strings.TrimSuffix(entry, ".java") + b, err := read(stem + ".class") + if err != nil { + return nil, err + } + top, err := parseClass(b) + if err != nil { + return nil, fmt.Errorf("%s: %w", stem+".class", err) + } + load := func(binary string) (*classFile, bool) { + b, err := read(binary + ".class") + if err != nil { + return nil, false + } + c, err := parseClass(b) + return c, err == nil + } + exists := func(binary string) bool { return files[binary+".class"] != nil } + return writeStub(top, filepath.Base(a.Path), load, exists), nil +} + +// CacheDir is the directory under state that holds the copies of the sources +// of every dependency. +func CacheDir(state string) string { return filepath.Join(state, "dependency-sources") } + +// Extract writes a read-only copy of library path p under CacheDir(state) +// and returns its absolute path. An existing copy is reused: its directory is +// named after the jar's identity (path, size and modification time), so a +// changed jar never reuses an old copy. The copy is written to a temporary +// file first and renamed into place, so a reader never sees half a file. +func (s *Set) Extract(state, p string) (string, error) { + a, entry, ok := s.split(p) + if !ok { + return "", fmt.Errorf("%s: %w", p, fs.ErrNotExist) + } + dst := filepath.Join(CacheDir(state), a.dir, filepath.FromSlash(entry)) + if fi, err := os.Stat(dst); err == nil && fi.Mode().IsRegular() { + return dst, nil + } + b, err := s.Read(p) + if err != nil { + return "", err + } + if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { + return "", fmt.Errorf("extract dependency source: %w", err) + } + tmp, err := os.CreateTemp(filepath.Dir(dst), ".extract-*") + if err != nil { + return "", fmt.Errorf("extract dependency source: %w", err) + } + _, werr := tmp.Write(b) + cerr := tmp.Close() + if err := errors.Join(werr, cerr); err != nil { + os.Remove(tmp.Name()) + return "", fmt.Errorf("extract dependency source: %w", err) + } + if err := os.Chmod(tmp.Name(), 0o444); err != nil { + os.Remove(tmp.Name()) + return "", fmt.Errorf("extract dependency source: %w", err) + } + if err := os.Rename(tmp.Name(), dst); err != nil { + os.Remove(tmp.Name()) + // Another request may have extracted the same entry; Windows refuses + // to replace a read-only file. + if fi, serr := os.Stat(dst); serr == nil && fi.Mode().IsRegular() { + return dst, nil + } + return "", fmt.Errorf("extract dependency source: %w", err) + } + return dst, nil +} + +// PathOf maps file, an absolute path, back to the library path it is a copy +// of, when it lies under CacheDir(state). +func (s *Set) PathOf(state, file string) (string, bool) { + rel, err := filepath.Rel(CacheDir(state), file) + if err != nil || rel == "." || strings.HasPrefix(rel, "..") || filepath.IsAbs(rel) { + return "", false + } + dir, entry, ok := strings.Cut(filepath.ToSlash(rel), "/") + a := s.byDir[dir] + if !ok || a == nil { + return "", false + } + p := a.id + Sep + entry + return p, s.Has(p) +} + +// FQN derives the fully qualified name of the class library path p declares. +func FQN(p string) (string, bool) { + _, entry, ok := strings.Cut(p, Sep) + if !ok { + return "", false + } + return codenav.LibraryFQN(entry) +} + +// validEntry refuses entry names that would escape the cache directory. +func validEntry(name string) bool { + if name == "" || strings.HasPrefix(name, "/") || strings.Contains(name, "\\") || strings.Contains(name, ":") || strings.Contains(name, Sep) { + return false + } + return path.Clean(name) == name && !strings.HasPrefix(name, "../") && name != ".." +} + +// sanitize keeps a coordinate usable as a directory name. +func sanitize(v string) string { + return strings.Map(func(r rune) rune { + if r >= '0' && r <= '9' || r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r == '.' || r == '_' || r == '-' { + return r + } + return '_' + }, v) +} diff --git a/internal/deps/maven.go b/internal/deps/maven.go new file mode 100644 index 0000000..d68a538 --- /dev/null +++ b/internal/deps/maven.go @@ -0,0 +1,608 @@ +package deps + +import ( + "bufio" + "bytes" + "context" + "encoding/xml" + "fmt" + "io" + "os" + "path/filepath" + "sort" + "strings" + "unicode/utf8" +) + +// Maven dependencies, read without running Maven: the working tree's +// pom.xml files are read with their parents and imported BOMs, and their +// dependencies are followed through the POMs in the local repository, the +// way Maven mediates them (nearest declaration wins, the root's dependency +// management sets versions, test and provided dependencies and optional ones +// are not transitive, exclusions apply). Only what the local repository +// already holds is used: nothing is downloaded, and a dependency whose jar +// was never fetched is left out. Profiles, plugins and settings mirrors are +// not read; a version that names an unknown property or a range resolves to +// the highest version in the local repository. + +const ( + // maxArtifacts bounds the dependencies of one working tree. + maxArtifacts = 4000 + // maxPOMs bounds the POMs read for one resolution. + maxPOMs = 8000 + // maxParents bounds a parent chain. + maxParents = 32 + // maxPOMSize skips POM files too large to be hand-written. + maxPOMSize = 4 << 20 +) + +// Jar is a dependency archive in the local repository. +type Jar struct { + Coord string // "group:artifact:version", with ":classifier" when there is one + Path string // the jar + Sources string // the jar's -sources.jar next to it, "" when it was not downloaded +} + +type pomDep struct { + GroupID string `xml:"groupId"` + ArtifactID string `xml:"artifactId"` + Version string `xml:"version"` + Type string `xml:"type"` + Classifier string `xml:"classifier"` + Scope string `xml:"scope"` + Optional string `xml:"optional"` + Exclusions []struct { + GroupID string `xml:"groupId"` + ArtifactID string `xml:"artifactId"` + } `xml:"exclusions>exclusion"` +} + +type pomXML struct { + Parent struct { + GroupID string `xml:"groupId"` + ArtifactID string `xml:"artifactId"` + Version string `xml:"version"` + RelativePath *string `xml:"relativePath"` + } `xml:"parent"` + GroupID string `xml:"groupId"` + ArtifactID string `xml:"artifactId"` + Version string `xml:"version"` + Packaging string `xml:"packaging"` + Properties properties `xml:"properties"` + Managed []pomDep `xml:"dependencyManagement>dependencies>dependency"` + Dependencies []pomDep `xml:"dependencies>dependency"` +} + +type properties map[string]string + +func (p *properties) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { + *p = properties{} + for { + tok, err := d.Token() + if err != nil { + return err + } + switch t := tok.(type) { + case xml.StartElement: + var v string + if err := d.DecodeElement(&v, &t); err != nil { + return err + } + (*p)[t.Name.Local] = strings.TrimSpace(v) + case xml.EndElement: + return nil + } + } +} + +// model is a POM with its parents, imports and interpolation applied. +type model struct { + group, artifact, version string + packaging string + props map[string]string + managed map[string]pomDep // by depKey + deps []pomDep +} + +func depKey(d pomDep) string { + t := d.Type + if t == "" { + t = "jar" + } + return d.GroupID + ":" + d.ArtifactID + ":" + t + ":" + d.Classifier +} + +func gaOf(d pomDep) string { return d.GroupID + ":" + d.ArtifactID } + +// maven resolves one working tree. +type maven struct { + ctx context.Context + repo *localRepo + raw map[string]*pomXML // parsed POM files by path; nil marks a failure + models map[string]*model // effective models by path + building map[string]bool // cycle guard + tree map[string]string // workspace POM path by "group:artifact" + reads int + logf func(string, ...any) +} + +// MavenJars returns the dependency jars of the Maven projects whose pom.xml +// files are poms (absolute paths, in the working tree), in classpath order: +// the modules' own dependencies first, then theirs. repo is the local +// repository; a module of the working tree is never one of the jars. +func MavenJars(ctx context.Context, poms []string, repo string, logf func(string, ...any)) ([]Jar, error) { + if logf == nil { + logf = func(string, ...any) {} + } + m := &maven{ctx: ctx, repo: &localRepo{root: repo, versions: map[string][]string{}}, raw: map[string]*pomXML{}, + models: map[string]*model{}, building: map[string]bool{}, tree: map[string]string{}, logf: logf} + sort.Strings(poms) + var roots []*model + for _, p := range poms { + if mo := m.model(p); mo != nil { + m.tree[mo.group+":"+mo.artifact] = p + roots = append(roots, mo) + } + } + if err := ctx.Err(); err != nil { + return nil, err + } + type item struct { + dep pomDep + excluded map[string]bool + managed map[string]pomDep + } + var queue []item + for _, r := range roots { + for _, d := range r.deps { + queue = append(queue, item{dep: d, managed: r.managed}) + } + } + seen := map[string]bool{} + var out []Jar + for len(queue) > 0 && len(out) < maxArtifacts { + if err := ctx.Err(); err != nil { + return nil, err + } + it := queue[0] + queue = queue[1:] + d := it.dep + if d.GroupID == "" || d.ArtifactID == "" || seen[gaOf(d)+":"+d.Classifier] { + continue + } + seen[gaOf(d)+":"+d.Classifier] = true + if _, own := m.tree[gaOf(d)]; own { + continue // a module of the working tree: its sources are indexed + } + version := d.Version + if md, ok := it.managed[depKey(d)]; ok && md.Version != "" { + version = md.Version + } + version = m.repo.pick(d.GroupID, d.ArtifactID, version) + if version == "" { + continue + } + typ := d.Type + if typ == "" { + typ = "jar" + } + if typ != "pom" && typ != "test-jar" { + if jar, ok := m.repo.jar(d.GroupID, d.ArtifactID, version, d.Classifier); ok { + coord := d.GroupID + ":" + d.ArtifactID + ":" + version + if d.Classifier != "" { + coord += ":" + d.Classifier + } + j := Jar{Coord: coord, Path: jar} + if d.Classifier == "" { + j.Sources, _ = m.repo.file(d.GroupID, d.ArtifactID, version, "sources", "jar") + } + out = append(out, j) + } + } + pom, ok := m.repo.file(d.GroupID, d.ArtifactID, version, "", "pom") + if !ok { + continue + } + dm := m.model(pom) + if dm == nil { + continue + } + excluded := map[string]bool{} + for k := range it.excluded { + excluded[k] = true + } + for _, e := range d.Exclusions { + excluded[e.GroupID+":"+e.ArtifactID] = true + } + for _, td := range dm.deps { + scope := td.Scope + if scope == "" { + if md, ok := dm.managed[depKey(td)]; ok { + scope = md.Scope + } + } + if scope == "test" || scope == "provided" || scope == "system" || scope == "import" || td.Optional == "true" { + continue + } + if excluded[gaOf(td)] || excluded[td.GroupID+":*"] || excluded["*:*"] { + continue + } + if td.Version == "" { + if md, ok := dm.managed[depKey(td)]; ok { + td.Version = md.Version + } + } + queue = append(queue, item{dep: td, excluded: excluded, managed: it.managed}) + } + } + return out, nil +} + +// readPOM parses the POM at path once. +func (m *maven) readPOM(path string) *pomXML { + if p, ok := m.raw[path]; ok { + return p + } + m.raw[path] = nil + if m.reads >= maxPOMs { + return nil + } + m.reads++ + fi, err := os.Stat(path) + if err != nil || !fi.Mode().IsRegular() || fi.Size() > maxPOMSize { + return nil + } + b, err := os.ReadFile(path) + if err != nil { + return nil + } + var p pomXML + d := xml.NewDecoder(bytes.NewReader(b)) + d.CharsetReader = charsetReader + if err := d.Decode(&p); err != nil { + m.logf("skipping %s: %v", path, err) + return nil + } + m.raw[path] = &p + return &p +} + +// model returns the effective model of the POM at path, nil when it cannot +// be read. +func (m *maven) model(path string) *model { + if mo, ok := m.models[path]; ok { + return mo + } + if m.building[path] || len(m.building) > maxParents || m.ctx.Err() != nil { + return nil + } + m.building[path] = true + defer delete(m.building, path) + p := m.readPOM(path) + if p == nil { + m.models[path] = nil + return nil + } + mo := &model{props: map[string]string{}, managed: map[string]pomDep{}} + var parent *model + if p.Parent.ArtifactID != "" { + parent = m.parentOf(path, p) + } + if parent != nil { + for k, v := range parent.props { + mo.props[k] = v + } + for k, v := range parent.managed { + mo.managed[k] = v + } + } + for k, v := range p.Properties { + mo.props[k] = v + } + mo.group, mo.artifact, mo.version, mo.packaging = p.GroupID, p.ArtifactID, p.Version, p.Packaging + if mo.group == "" { + mo.group = p.Parent.GroupID + } + if mo.version == "" { + mo.version = p.Parent.Version + } + builtin := map[string]string{ + "project.groupId": mo.group, "project.artifactId": mo.artifact, "project.version": mo.version, + "pom.groupId": mo.group, "pom.artifactId": mo.artifact, "pom.version": mo.version, + "groupId": mo.group, "artifactId": mo.artifact, "version": mo.version, + "project.parent.groupId": p.Parent.GroupID, "project.parent.version": p.Parent.Version, + "parent.version": p.Parent.Version, "project.basedir": filepath.Dir(path), "basedir": filepath.Dir(path), + } + for k, v := range builtin { + if _, ok := mo.props[k]; !ok || strings.HasPrefix(k, "project.") || strings.HasPrefix(k, "pom.") { + mo.props[k] = v + } + } + mo.group = m.interpolate(mo.group, mo.props) + mo.version = m.interpolate(mo.version, mo.props) + mo.props["project.version"], mo.props["project.groupId"] = mo.version, mo.group + + // The POM's own management wins over its parent's, and both over what + // imported BOMs manage, the first import first. + own := map[string]pomDep{} + var imports []pomDep + for _, d := range p.Managed { + d = m.interpolateDep(d, mo.props) + if d.Scope == "import" && d.Type == "pom" { + imports = append(imports, d) + continue + } + own[depKey(d)] = d + } + for k, v := range own { + mo.managed[k] = v + } + for _, d := range imports { + v := m.repo.pick(d.GroupID, d.ArtifactID, d.Version) + bom, ok := m.repo.file(d.GroupID, d.ArtifactID, v, "", "pom") + if !ok { + continue + } + if bm := m.model(bom); bm != nil { + for k, v := range bm.managed { + if _, ok := mo.managed[k]; !ok { + mo.managed[k] = v + } + } + } + } + byKey := map[string]int{} + if parent != nil { + for _, d := range parent.deps { + byKey[depKey(d)] = len(mo.deps) + mo.deps = append(mo.deps, d) + } + } + for _, d := range p.Dependencies { + d = m.interpolateDep(d, mo.props) + if i, ok := byKey[depKey(d)]; ok { + mo.deps[i] = d + continue + } + byKey[depKey(d)] = len(mo.deps) + mo.deps = append(mo.deps, d) + } + for i, d := range mo.deps { + md, ok := mo.managed[depKey(d)] + if !ok { + continue + } + if d.Version == "" { + d.Version = md.Version + } + if d.Scope == "" { + d.Scope = md.Scope + } + if len(d.Exclusions) == 0 { + d.Exclusions = md.Exclusions + } + mo.deps[i] = d + } + m.models[path] = mo + return mo +} + +// parentOf finds the parent of the POM at path: the file its relativePath +// names (../pom.xml by default) when that is the parent, else the working +// tree's POM of those coordinates, else the local repository's. +func (m *maven) parentOf(path string, p *pomXML) *model { + rel := "../pom.xml" + if p.Parent.RelativePath != nil { + rel = strings.TrimSpace(*p.Parent.RelativePath) + } + if rel != "" { + cand := filepath.Join(filepath.Dir(path), filepath.FromSlash(rel)) + if fi, err := os.Stat(cand); err == nil && fi.IsDir() { + cand = filepath.Join(cand, "pom.xml") + } + if pp := m.readPOM(cand); pp != nil && pp.ArtifactID == p.Parent.ArtifactID { + return m.model(cand) + } + } + if wp, ok := m.tree[p.Parent.GroupID+":"+p.Parent.ArtifactID]; ok { + return m.model(wp) + } + v := m.repo.pick(p.Parent.GroupID, p.Parent.ArtifactID, p.Parent.Version) + if pom, ok := m.repo.file(p.Parent.GroupID, p.Parent.ArtifactID, v, "", "pom"); ok { + return m.model(pom) + } + return nil +} + +func (m *maven) interpolateDep(d pomDep, props map[string]string) pomDep { + d.GroupID = m.interpolate(d.GroupID, props) + d.ArtifactID = m.interpolate(d.ArtifactID, props) + d.Version = m.interpolate(d.Version, props) + d.Type = m.interpolate(d.Type, props) + d.Classifier = m.interpolate(d.Classifier, props) + d.Scope = m.interpolate(d.Scope, props) + d.Optional = m.interpolate(d.Optional, props) + return d +} + +// interpolate replaces ${name} with the property's value, recursively; an +// unknown property stays as written. +func (m *maven) interpolate(s string, props map[string]string) string { + s = strings.TrimSpace(s) + for depth := 0; depth < 10 && strings.Contains(s, "${"); depth++ { + var b strings.Builder + rest := s + changed := false + for { + i := strings.Index(rest, "${") + if i < 0 { + b.WriteString(rest) + break + } + j := strings.IndexByte(rest[i:], '}') + if j < 0 { + b.WriteString(rest) + break + } + name := rest[i+2 : i+j] + b.WriteString(rest[:i]) + if v, ok := props[name]; ok { + b.WriteString(v) + changed = true + } else if v, ok := strings.CutPrefix(name, "env."); ok && os.Getenv(v) != "" { + b.WriteString(os.Getenv(v)) + changed = true + } else { + b.WriteString(rest[i : i+j+1]) + } + rest = rest[i+j+1:] + } + s = b.String() + if !changed { + break + } + } + return s +} + +// charsetReader decodes the single-byte encodings POMs declare besides +// UTF-8: ISO-8859-1 (Latin-1) and US-ASCII, whose bytes are their code +// points. +func charsetReader(label string, in io.Reader) (io.Reader, error) { + switch strings.ToLower(strings.TrimSpace(label)) { + case "iso-8859-1", "iso8859-1", "latin1", "latin-1", "us-ascii", "ascii", "windows-1252", "cp1252": + return latin1Reader{bufio.NewReader(in)}, nil + } + return nil, fmt.Errorf("unsupported encoding %q", label) +} + +type latin1Reader struct{ r *bufio.Reader } + +// Read decodes bytes as code points, writing at most len(p) bytes of UTF-8; +// an encoded character never spans two reads. +func (l latin1Reader) Read(p []byte) (int, error) { + n := 0 + for n+2 <= len(p) { + c, err := l.r.ReadByte() + if err != nil { + if n > 0 && err == io.EOF { + return n, nil + } + return n, err + } + n += utf8.EncodeRune(p[n:], rune(c)) + } + if n == 0 && len(p) > 0 { + return 0, io.ErrShortBuffer + } + return n, nil +} + +// LocalRepository returns the local Maven repository: the localRepository +// of the user's settings.xml when set, else ~/.m2/repository. +func LocalRepository(home string) string { + def := filepath.Join(home, ".m2", "repository") + b, err := os.ReadFile(filepath.Join(home, ".m2", "settings.xml")) + if err != nil { + return def + } + var s struct { + LocalRepository string `xml:"localRepository"` + } + if xml.Unmarshal(b, &s) != nil { + return def + } + r := strings.TrimSpace(s.LocalRepository) + if r == "" { + return def + } + r = strings.ReplaceAll(r, "${user.home}", home) + if strings.HasPrefix(r, "~"+string(filepath.Separator)) || strings.HasPrefix(r, "~/") { + r = filepath.Join(home, r[2:]) + } + if !filepath.IsAbs(r) { + return def + } + return r +} + +// localRepo reads a Maven repository layout. +type localRepo struct { + root string + versions map[string][]string // listed version directories by "group:artifact" +} + +func (r *localRepo) dir(group, artifact string) string { + return filepath.Join(r.root, filepath.FromSlash(strings.ReplaceAll(group, ".", "/")), artifact) +} + +// file returns group:artifact:version's file with classifier and extension +// when the repository has it. +func (r *localRepo) file(group, artifact, version, classifier, ext string) (string, bool) { + if !safeSegment(group) || !safeSegment(artifact) || !safeSegment(version) || classifier != "" && !safeSegment(classifier) { + return "", false + } + name := artifact + "-" + version + if classifier != "" { + name += "-" + classifier + } + p := filepath.Join(r.dir(group, artifact), version, name+"."+ext) + fi, err := os.Stat(p) + if err != nil || !fi.Mode().IsRegular() { + return "", false + } + return p, true +} + +func (r *localRepo) jar(group, artifact, version, classifier string) (string, bool) { + return r.file(group, artifact, version, classifier, "jar") +} + +// pick returns the version to use: version itself when the repository has +// its POM or jar, else the highest version it has (a range, an unresolved +// property or a version that was never downloaded). +func (r *localRepo) pick(group, artifact, version string) string { + if version != "" && !strings.ContainsAny(version, "${[(,") { + if _, ok := r.file(group, artifact, version, "", "pom"); ok { + return version + } + if _, ok := r.jar(group, artifact, version, ""); ok { + return version + } + } + vs := r.list(group, artifact) + if len(vs) == 0 { + return "" + } + return vs[len(vs)-1] +} + +// list returns the versions of group:artifact in the repository, lowest +// first. +func (r *localRepo) list(group, artifact string) []string { + key := group + ":" + artifact + if vs, ok := r.versions[key]; ok { + return vs + } + var vs []string + if safeSegment(group) && safeSegment(artifact) { + entries, _ := os.ReadDir(r.dir(group, artifact)) + for _, e := range entries { + if e.IsDir() && safeSegment(e.Name()) { + if _, ok := r.file(group, artifact, e.Name(), "", "pom"); ok { + vs = append(vs, e.Name()) + } else if _, ok := r.jar(group, artifact, e.Name(), ""); ok { + vs = append(vs, e.Name()) + } + } + } + } + sort.Slice(vs, func(a, b int) bool { return compareVersions(vs[a], vs[b]) < 0 }) + r.versions[key] = vs + return vs +} + +// safeSegment refuses coordinates that would leave the repository. +func safeSegment(s string) bool { + return s != "" && s != "." && s != ".." && !strings.ContainsAny(s, `/\:`+"\x00") +} diff --git a/internal/deps/signature.go b/internal/deps/signature.go new file mode 100644 index 0000000..9002794 --- /dev/null +++ b/internal/deps/signature.go @@ -0,0 +1,241 @@ +package deps + +import "strings" + +// Descriptors and generic signatures (JVMS 4.3, 4.7.9.1), read into a small +// type tree the stub writer renders as Java source. + +// jtype is one Java type. +type jtype struct { + prim string // a primitive or "void"; "" otherwise + tvar string // a type variable + parts []classPart // a class type: parts[0].name is the binary name, the others are member classes + class string // a class type's binary name, member classes joined with '$' + dims int + wild byte // as a type argument: '*' (unbounded), '+' (extends), '-' (super) or 0 +} + +type classPart struct { + name string + args []jtype +} + +type typeParam struct { + name string + bounds []jtype +} + +// methodSig is a method's type parameters, parameter types and result. +type methodSig struct { + tparams []typeParam + params []jtype + ret jtype +} + +// classSig is a class's type parameters and supertypes. +type classSig struct { + tparams []typeParam + super jtype + interfaces []jtype +} + +type sigParser struct { + s string + i int + bad bool +} + +func (p *sigParser) peek() byte { + if p.i < len(p.s) { + return p.s[p.i] + } + return 0 +} + +func (p *sigParser) expect(c byte) { + if p.peek() != c { + p.bad = true + return + } + p.i++ +} + +func (p *sigParser) ident(stops string) string { + s := p.i + for p.i < len(p.s) && !strings.ContainsRune(stops, rune(p.s[p.i])) { + p.i++ + } + if p.i == s { + p.bad = true + } + return p.s[s:p.i] +} + +var basic = map[byte]string{'B': "byte", 'C': "char", 'D': "double", 'F': "float", 'I': "int", 'J': "long", + 'S': "short", 'Z': "boolean", 'V': "void"} + +// javaType reads a field type: a primitive, a class or type variable, or an +// array of one. +func (p *sigParser) javaType() jtype { + dims := 0 + for p.peek() == '[' { + p.i++ + dims++ + } + var t jtype + switch c := p.peek(); c { + case 'L': + t = p.classType() + case 'T': + p.i++ + t = jtype{tvar: p.ident(";")} + p.expect(';') + default: + name, ok := basic[c] + if !ok || c == 'V' && dims > 0 { + p.bad = true + return jtype{} + } + p.i++ + t = jtype{prim: name} + } + t.dims = dims + return t +} + +// classType reads "Lpkg/Name.Inner;". +func (p *sigParser) classType() jtype { + p.expect('L') + var t jtype + for !p.bad { + name := p.ident("<.;") + part := classPart{name: name} + if p.peek() == '<' { + part.args = p.typeArgs() + } + t.parts = append(t.parts, part) + if p.peek() == '.' { + p.i++ + continue + } + p.expect(';') + break + } + names := make([]string, len(t.parts)) + for i, part := range t.parts { + names[i] = part.name + } + t.class = strings.Join(names, "$") + return t +} + +func (p *sigParser) typeArgs() []jtype { + p.expect('<') + var out []jtype + for !p.bad && p.peek() != '>' { + switch c := p.peek(); c { + case '*': + p.i++ + out = append(out, jtype{wild: '*'}) + case '+', '-': + p.i++ + t := p.javaType() + t.wild = c + out = append(out, t) + default: + out = append(out, p.javaType()) + } + if p.i >= len(p.s) { + p.bad = true + } + } + p.expect('>') + return out +} + +func (p *sigParser) typeParams() []typeParam { + if p.peek() != '<' { + return nil + } + p.i++ + var out []typeParam + for !p.bad && p.peek() != '>' { + tp := typeParam{name: p.ident(":")} + for !p.bad && p.peek() == ':' { + p.i++ + if c := p.peek(); c == 'L' || c == 'T' || c == '[' { + tp.bounds = append(tp.bounds, p.javaType()) + } + } + out = append(out, tp) + if p.i >= len(p.s) { + p.bad = true + } + } + p.expect('>') + return out +} + +func (p *sigParser) done() bool { return !p.bad && p.i == len(p.s) } + +func parseFieldDescriptor(s string) (jtype, error) { + p := &sigParser{s: s} + t := p.javaType() + if !p.done() || t.prim == "void" { + return jtype{}, errClassFormat + } + return t, nil +} + +// parseMethodDescriptor reads "(params)result" (a descriptor or the +// parameter part of a signature without type parameters or throws). +func parseMethodDescriptor(s string) ([]jtype, jtype, error) { + m, err := parseMethodSignature(s) + return m.params, m.ret, err +} + +func parseMethodSignature(s string) (methodSig, error) { + p := &sigParser{s: s} + var m methodSig + m.tparams = p.typeParams() + p.expect('(') + for !p.bad && p.peek() != ')' { + t := p.javaType() + if t.prim == "void" { + p.bad = true + } + m.params = append(m.params, t) + if p.i >= len(p.s) { + p.bad = true + } + } + p.expect(')') + m.ret = p.javaType() + for !p.bad && p.peek() == '^' { // throws: not shown + p.i++ + p.javaType() + } + if !p.done() { + return methodSig{}, errClassFormat + } + return m, nil +} + +func parseClassSignature(s string) (classSig, error) { + p := &sigParser{s: s} + var c classSig + c.tparams = p.typeParams() + c.super = p.classType() + for !p.bad && p.peek() == 'L' { + c.interfaces = append(c.interfaces, p.classType()) + } + if !p.done() { + return classSig{}, errClassFormat + } + return c, nil +} + +// classOf is the type of a binary name, as the constant pool names classes. +func classOf(binary string) jtype { + return jtype{parts: []classPart{{name: binary}}, class: binary} +} diff --git a/internal/deps/stub.go b/internal/deps/stub.go new file mode 100644 index 0000000..0970475 --- /dev/null +++ b/internal/deps/stub.go @@ -0,0 +1,558 @@ +package deps + +import ( + "sort" + "strconv" + "strings" +) + +// Signature stubs: Java source written from the class files of a jar that +// has no sources attached. A stub declares what the class file declares and +// navigation can reach (the class and its member classes, their supertypes, +// and their non-private fields, constructors and methods with parameter +// names when the class file records them), so codenav parses it like any +// other library source and go to definition lands on a readable declaration. +// Method bodies are "{ /* compiled code */ }". The output is a function of +// the class files alone, so the copy an editor opens and the text codenav +// parsed agree on every position. + +// maxNesting bounds member classes inside member classes. +const maxNesting = 8 + +// stubHeader starts every stub; origin names the jar. +func stubHeader(origin string) string { + return "// Signatures read from " + origin + " by Java Navigation; no sources are attached.\n" +} + +type stubWriter struct { + top *classFile + pkg string // internal form: "a/b" + load func(binary string) (*classFile, bool) + exists func(binary string) bool // a class of this jar + inner map[string]innerClass // every InnerClasses entry seen, by inner binary name + own map[string]bool // simple names of the top class and its member classes + refs map[string]bool // top-level binary names referenced + b strings.Builder + indent int + written int +} + +// writeStub returns the stub of top, a top-level class. load reads another +// class of the same jar by binary name; exists reports whether the jar has +// one without reading it. +func writeStub(top *classFile, origin string, load func(binary string) (*classFile, bool), exists func(binary string) bool) []byte { + w := &stubWriter{top: top, load: load, exists: exists, inner: map[string]innerClass{}, + own: map[string]bool{}, refs: map[string]bool{}} + if i := strings.LastIndexByte(top.name, '/'); i >= 0 { + w.pkg = top.name[:i] + } + w.own[simpleOf(top.name)] = true + w.class(top, top.access, 0) + body := w.b.String() + + names := w.names() + var out strings.Builder + out.WriteString(stubHeader(origin)) + if w.pkg != "" { + out.WriteString("package " + strings.ReplaceAll(w.pkg, "/", ".") + ";\n") + } + var imports []string + for bin, name := range names { + if !strings.Contains(name, ".") && w.packageOf(bin) != w.pkg && w.packageOf(bin) != "java/lang" { + imports = append(imports, dotted(bin)) + } + } + sort.Strings(imports) + if len(imports) > 0 { + out.WriteByte('\n') + for _, im := range imports { + out.WriteString("import " + im + ";\n") + } + } + out.WriteByte('\n') + // Replace the placeholders "\x00binary\x00" with the chosen names. + for { + i := strings.IndexByte(body, 0) + if i < 0 { + out.WriteString(body) + break + } + j := strings.IndexByte(body[i+1:], 0) + if j < 0 { + out.WriteString(body) + break + } + out.WriteString(body[:i]) + out.WriteString(w.render(body[i+1:i+1+j], names)) + body = body[i+2+j:] + } + return []byte(out.String()) +} + +func simpleOf(binary string) string { + return binary[strings.LastIndexAny(binary, "/$")+1:] +} + +func dotted(binary string) string { return strings.ReplaceAll(binary, "/", ".") } + +func (w *stubWriter) packageOf(binary string) string { + if i := strings.LastIndexByte(binary, '/'); i >= 0 { + return binary[:i] + } + return "" +} + +// topOf splits a binary name into its top-level class and the member class +// path below it, as InnerClasses entries describe them; without an entry a +// '$' is taken as a member class separator, which is what javac emits. +func (w *stubWriter) topOf(binary string) (string, []string) { + var path []string + for b := binary; ; { + if ic, ok := w.inner[b]; ok && ic.outer != "" && ic.name != "" { + path = append([]string{ic.name}, path...) + b = ic.outer + continue + } + pkg := w.packageOf(b) + rest := b[len(pkg):] + if pkg != "" { + rest = rest[1:] + } + segs := strings.Split(rest, "$") + if len(segs) > 1 && segs[0] != "" && !strings.Contains(rest, "$$") { + path = append(segs[1:], path...) + b = b[:len(b)-len(rest)] + segs[0] + } + return b, path + } +} + +// names decides how each referenced top-level class is written: its simple +// name when that is unambiguous (the class itself, its package, java.lang, +// or an import), else its fully qualified name. +func (w *stubWriter) names() map[string]string { + out := map[string]string{} + bySimple := map[string][]string{} + for bin := range w.refs { + bySimple[simpleOf(bin)] = append(bySimple[simpleOf(bin)], bin) + } + for simple, bins := range bySimple { + sort.Strings(bins) + for k, bin := range bins { + full := dotted(bin) + switch { + case bin == w.top.name: + out[bin] = simple + case w.own[simple]: + // A member class of the same name would shadow it. + out[bin] = full + case len(bins) > 1 && k > 0: + out[bin] = full + case w.packageOf(bin) == "java/lang" && w.exists(w.pkg+"/"+simple): + // The stub's own package declares the name. + out[bin] = full + case strings.Contains(simple, "$"): + out[bin] = full + default: + out[bin] = simple + } + } + } + return out +} + +func (w *stubWriter) render(binary string, names map[string]string) string { + top, path := w.topOf(binary) + s := names[top] + if s == "" { + s = dotted(top) + } + for _, p := range path { + s += "." + p + } + return s +} + +// ref returns the placeholder of a class and records its top-level class. +func (w *stubWriter) ref(binary string) string { + top, _ := w.topOf(binary) + w.refs[top] = true + return "\x00" + binary + "\x00" +} + +func (w *stubWriter) line(s string) { + w.b.WriteString(strings.Repeat(" ", w.indent)) + w.b.WriteString(s) + w.b.WriteByte('\n') +} + +func (w *stubWriter) typ(t jtype) string { + var s string + switch { + case t.wild == '*': + return "?" + case t.prim != "": + s = t.prim + case t.tvar != "": + s = t.tvar + case len(t.parts) > 0: + for i, p := range t.parts { + if i == 0 { + s = w.ref(p.name) + } else { + s += "." + p.name + } + if len(p.args) > 0 { + args := make([]string, len(p.args)) + for k, a := range p.args { + args[k] = w.typ(a) + } + s += "<" + strings.Join(args, ", ") + ">" + } + } + default: + s = "Object" + } + s += strings.Repeat("[]", t.dims) + switch t.wild { + case '+': + return "? extends " + s + case '-': + return "? super " + s + } + return s +} + +func (w *stubWriter) typeParams(tps []typeParam) string { + if len(tps) == 0 { + return "" + } + parts := make([]string, len(tps)) + for i, tp := range tps { + s := tp.name + var bounds []string + for _, b := range tp.bounds { + if b.class == "java/lang/Object" && len(tp.bounds) == 1 { + continue + } + bounds = append(bounds, w.typ(b)) + } + if len(bounds) > 0 { + s += " extends " + strings.Join(bounds, " & ") + } + parts[i] = s + } + return "<" + strings.Join(parts, ", ") + ">" +} + +// class writes c, whose access flags are access (a member class's come +// from its outer class's InnerClasses entry). +func (w *stubWriter) class(c *classFile, access uint16, depth int) { + for _, ic := range c.inner { + if _, ok := w.inner[ic.inner]; !ok { + w.inner[ic.inner] = ic + } + } + var members []innerClass + for _, ic := range c.inner { + if ic.outer == c.name && ic.name != "" && ic.access&(accPrivate|accSynthetic) == 0 && validIdent(ic.name) { + members = append(members, ic) + w.own[ic.name] = true + } + } + simple := simpleOf(c.name) + if depth > 0 { + if ic, ok := w.inner[c.name]; ok && ic.name != "" { + simple = ic.name + } + } + kind := "class" + isRecord := c.super == "java/lang/Record" && c.record != nil + switch { + case access&accAnnotation != 0: + kind = "@interface" + case access&accInterface != 0: + kind = "interface" + case access&accEnum != 0 && c.super == "java/lang/Enum": + kind = "enum" + case isRecord: + kind = "record" + } + var mods []string + mods = append(mods, visibility(access)...) + if depth > 0 && access&accStatic != 0 && kind == "class" { + mods = append(mods, "static") + } + if access&accAbstract != 0 && kind == "class" { + mods = append(mods, "abstract") + } + if access&accFinal != 0 && kind == "class" { + mods = append(mods, "final") + } + head := strings.Join(append(mods, kind, simple), " ") + + sig := classSig{super: classOf(c.super)} + for _, i := range c.interfaces { + sig.interfaces = append(sig.interfaces, classOf(i)) + } + if c.signature != "" { + if s, err := parseClassSignature(c.signature); err == nil { + sig = s + } + } + head += w.typeParams(sig.tparams) + if isRecord { + comps := make([]string, len(c.record)) + for i, rc := range c.record { + comps[i] = w.fieldType(rc) + " " + rc.name + } + head += "(" + strings.Join(comps, ", ") + ")" + } + if kind == "class" && c.super != "" && c.super != "java/lang/Object" { + head += " extends " + w.typ(sig.super) + } + var ifaces []string + for _, t := range sig.interfaces { + if kind == "@interface" && t.class == "java/lang/annotation/Annotation" { + continue + } + ifaces = append(ifaces, w.typ(t)) + } + if len(ifaces) > 0 { + if kind == "interface" { + head += " extends " + } else { + head += " implements " + } + head += strings.Join(ifaces, ", ") + } + w.line(head + " {") + w.indent++ + + if kind == "enum" { + var consts []string + for _, f := range c.fields { + if f.access&accEnum != 0 && validIdent(f.name) { + consts = append(consts, f.name) + } + } + for i, n := range consts { + if i == len(consts)-1 { + w.line(n + ";") + } else { + w.line(n + ",") + } + } + if len(consts) == 0 { + w.line(";") + } + } + for _, f := range c.fields { + if f.access&(accPrivate|accSynthetic) != 0 || f.access&accEnum != 0 || !validIdent(f.name) { + continue + } + if isRecord && f.access&accStatic == 0 { + continue // the components + } + var fm []string + if kind != "interface" && kind != "@interface" { + fm = visibility(f.access) + if f.access&accStatic != 0 { + fm = append(fm, "static") + } + if f.access&accFinal != 0 { + fm = append(fm, "final") + } + } + s := strings.Join(append(fm, w.fieldType(f), f.name), " ") + if f.constant != "" { + s += " = " + f.constant + } + w.line(s + ";") + } + outerParam := "" + if depth > 0 && access&accStatic == 0 && kind == "class" { + if ic, ok := w.inner[c.name]; ok { + outerParam = ic.outer + } + } + for _, m := range c.methods { + w.method(c, kind, simple, m, outerParam) + } + if depth < maxNesting { + for _, ic := range members { + nc, ok := w.load(ic.inner) + if !ok { + continue + } + w.class(nc, ic.access, depth+1) + } + } + w.indent-- + w.line("}") +} + +func visibility(access uint16) []string { + switch { + case access&accPublic != 0: + return []string{"public"} + case access&accProtected != 0: + return []string{"protected"} + } + return nil +} + +func (w *stubWriter) fieldType(f member) string { + if f.signature != "" { + p := &sigParser{s: f.signature} + if t := p.javaType(); p.done() { + return w.typ(t) + } + } + t, err := parseFieldDescriptor(f.desc) + if err != nil { + return "Object" + } + return w.typ(t) +} + +func (w *stubWriter) method(c *classFile, kind, simple string, m member, outerParam string) { + if m.access&(accPrivate|accSynthetic|accBridge) != 0 || m.name == "" { + return + } + ctor := m.name == "" + if !ctor && !validIdent(m.name) { + return + } + if kind == "enum" && (ctor || m.name == "values" && strings.HasPrefix(m.desc, "()") || + m.name == "valueOf" && strings.HasPrefix(m.desc, "(Ljava/lang/String;)")) { + return + } + if kind == "record" { + if ctor && m.desc == recordCtorDesc(c) { + return + } + if strings.HasPrefix(m.desc, "()") { + for _, rc := range c.record { + if rc.name == m.name { + return + } + } + } + // The members every record has, which javac generates final. + if m.access&accFinal != 0 && (m.name == "toString" && m.desc == "()Ljava/lang/String;" || + m.name == "hashCode" && m.desc == "()I" || m.name == "equals" && m.desc == "(Ljava/lang/Object;)Z") { + return + } + } + desc, err := parseMethodSignature(m.desc) + if err != nil { + return + } + names := m.params + if outerParam != "" && ctor && len(desc.params) > 0 && desc.params[0].class == outerParam && desc.params[0].dims == 0 { + // An inner class's constructor takes its outer instance first. + desc.params = desc.params[1:] + if len(names) > 0 { + names = names[1:] + } + } + sig := desc + if m.signature != "" { + if s, err := parseMethodSignature(m.signature); err == nil && len(s.params) <= len(desc.params) { + sig = s + } + } + if len(names) > len(sig.params) { + names = names[len(names)-len(sig.params):] + } + var mods []string + body := " { /* compiled code */ }" + switch kind { + case "interface", "@interface": + switch { + case m.access&accStatic != 0: + mods = append(mods, "static") + case m.access&accAbstract == 0: + mods = append(mods, "default") + default: + body = ";" + } + default: + mods = visibility(m.access) + if m.access&accStatic != 0 { + mods = append(mods, "static") + } + if m.access&accFinal != 0 { + mods = append(mods, "final") + } + if m.access&accAbstract != 0 { + mods = append(mods, "abstract") + body = ";" + } + if m.access&accNative != 0 { + mods = append(mods, "native") + body = ";" + } + } + if tp := w.typeParams(sig.tparams); tp != "" { + mods = append(mods, tp) + } + params := make([]string, len(sig.params)) + for i, t := range sig.params { + ts := "" + if i == len(sig.params)-1 && m.access&accVarargs != 0 && t.dims > 0 { + t.dims-- + ts = w.typ(t) + "..." + } else { + ts = w.typ(t) + } + name := "arg" + strconv.Itoa(i) + if i < len(names) && validIdent(names[i]) { + name = names[i] + } + params[i] = ts + " " + name + } + s := strings.Join(mods, " ") + if s != "" { + s += " " + } + if ctor { + s += simple + } else { + s += w.typ(sig.ret) + " " + m.name + } + s += "(" + strings.Join(params, ", ") + ")" + if kind == "@interface" && m.dflt != "" { + s += " default " + w.value(m.dflt) + body = ";" + } + w.line(s + body) +} + +// value renders an annotation default, whose class names are placeholders +// of their binary names already ("\x00a/b/C\x00"). +func (w *stubWriter) value(v string) string { + for i := 0; i < len(v); i++ { + if v[i] != 0 { + continue + } + j := strings.IndexByte(v[i+1:], 0) + if j < 0 { + break + } + w.ref(v[i+1 : i+1+j]) + i += j + 1 + } + return v +} + +func recordCtorDesc(c *classFile) string { + var b strings.Builder + b.WriteByte('(') + for _, rc := range c.record { + b.WriteString(rc.desc) + } + b.WriteString(")V") + return b.String() +} diff --git a/internal/deps/stub_test.go b/internal/deps/stub_test.go new file mode 100644 index 0000000..ad36111 --- /dev/null +++ b/internal/deps/stub_test.go @@ -0,0 +1,67 @@ +package deps + +import ( + "flag" + "os" + "path/filepath" + "strings" + "testing" +) + +var update = flag.Bool("update", false, "rewrite the golden stubs in testdata/stubs") + +// fixtureSet opens the committed fixture jars: com.example:lib:1.0 has class +// files only, com.example:helper:2.0 a sources jar too. +func fixtureSet(t *testing.T) *Set { + t.Helper() + dir := filepath.Join("testdata", "fixture") + s := Open([]Jar{ + {Coord: "com.example:lib:1.0", Path: filepath.Join(dir, "lib-1.0.jar")}, + {Coord: "com.example:helper:2.0", Path: filepath.Join(dir, "helper-2.0.jar"), Sources: filepath.Join(dir, "helper-2.0-sources.jar")}, + }, t.Logf) + if s.Len() != 2 { + t.Fatalf("opened %d jars, want 2", s.Len()) + } + return s +} + +func pathOf(t *testing.T, s *Set, entry string) string { + t.Helper() + for _, p := range s.Paths() { + if strings.HasSuffix(p, Sep+entry) { + return p + } + } + t.Fatalf("no library path for %s in %v", entry, s.Paths()) + return "" +} + +// The stub of every fixture class matches its golden file, and parses into +// the declarations the class file has. +func TestStubsMatchTheGoldenFiles(t *testing.T) { + s := fixtureSet(t) + for _, name := range []string{"Schema", "Repository", "Container", "Level", "Point", "Runner", "NoDebug", "Process"} { + t.Run(name, func(t *testing.T) { + b, err := s.Read(pathOf(t, s, "com/example/lib/"+name+".java")) + if err != nil { + t.Fatal(err) + } + golden := filepath.Join("testdata", "stubs", name+".java") + if *update { + if err := os.MkdirAll(filepath.Dir(golden), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(golden, b, 0o644); err != nil { + t.Fatal(err) + } + } + want, err := os.ReadFile(golden) + if err != nil { + t.Fatal(err) + } + if string(b) != string(want) { + t.Errorf("stub of %s:\n%s\nwant:\n%s", name, b, want) + } + }) + } +} diff --git a/internal/deps/testdata/fixture/build.sh b/internal/deps/testdata/fixture/build.sh new file mode 100755 index 0000000..12d6ade --- /dev/null +++ b/internal/deps/testdata/fixture/build.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# Rebuilds the fixture jars of the deps tests from the sources next to this +# script. Needs a JDK 17 or later; the tests only read the committed jars. +set -euo pipefail +cd "$(dirname "${BASH_SOURCE[0]}")" +out="$(mktemp -d)" +trap 'rm -rf "$out"' EXIT +javac --release 17 -g -d "$out/lib" $(find src -name '*.java') +javac --release 17 -g:none -d "$out/lib" nodebug/com/example/lib/NoDebug.java +javac --release 17 -g -d "$out/helper" sources/com/example/helper/Helper.java +# Fixed timestamps keep the jars byte-identical across rebuilds. +find "$out" -exec touch -t 202601010000 {} + +(cd "$out/lib" && jar --create --file "$OLDPWD/lib-1.0.jar" --date=2026-01-01T00:00:00Z $(find . -name '*.class' | sort)) +(cd "$out/helper" && jar --create --file "$OLDPWD/helper-2.0.jar" --date=2026-01-01T00:00:00Z $(find . -name '*.class' | sort)) +(cd sources && jar --create --file "../helper-2.0-sources.jar" --date=2026-01-01T00:00:00Z $(find . -name '*.java' | sort)) diff --git a/internal/deps/testdata/fixture/helper-2.0-sources.jar b/internal/deps/testdata/fixture/helper-2.0-sources.jar new file mode 100644 index 0000000000000000000000000000000000000000..08af177cf884b301ef1fff4b1fc167e7118f1886 GIT binary patch literal 631 zcmWIWW@Zs#;Nak3U|>*;0TOV)$-u(k>l)&y>*?pF|L+t712aQ_H#|1s zsXTZ%Upm8T@~PZUVr5E#G2a4fc+F(`UW9FvF!^oZ6r&R*>r#2~m&gB!k=}f}?_}-C zoBnBL%j#YCl~+fei_@% literal 0 HcmV?d00001 diff --git a/internal/deps/testdata/fixture/helper-2.0.jar b/internal/deps/testdata/fixture/helper-2.0.jar new file mode 100644 index 0000000000000000000000000000000000000000..38d304cefb4c03666094795a72204b413b7d90dc GIT binary patch literal 772 zcmWIWW@Zs#;Nak3U|>*;0TOV)$-u(k>l)&y>*?pF|L+t712aQ_H# zdHV+U4EB#_rg9t3Zof45#01@Uv5YLq0`{ACzFX;W@40cjTK^&bnVD6E3Wp|oOgZ=8 zLwWX!DFx5|-rnfHV##ES=l5eu%)Z~;wCJ6d_{@1bmZtGPw&8rT_R{0*(~{Gp+Kb18ORPB>_+>K?U$=2c;Qwt;jJ5N+Sqh3uMBz kBBdB~6ObJSifIJc31kw8_W*BJHjoBpAOyOOK?Up$0Kj$XJOBUy literal 0 HcmV?d00001 diff --git a/internal/deps/testdata/fixture/lib-1.0.jar b/internal/deps/testdata/fixture/lib-1.0.jar new file mode 100644 index 0000000000000000000000000000000000000000..c0b828a7b876c39825b4be1073251949919a41f2 GIT binary patch literal 8595 zcmb7J1yEesw#A)5a8Gb|3l0Gqm*DOaf;Spm8z;EC1}C@#x8N?pU4jKdaQQHq_lDt3 zrt0sm+jXn@p4I1`EoYsz<)t8@Fu>s8;K0DZh_&v&{%i1HP+&5mN<#D!vH-^0J}@vy zFnKB1hv&c`{(MOOw}Ub6pZM=!86jB-fT*GpgACw@%s{`i6g|TzvJ^ed(7aFwoRvrarZk+is@CXS0$A9bZ$enQCBXiwfzyEK_ z`(F*MEg6lS^(}2Ij2SJ=3>by2t?c#9tc-zVuN}-RjEsQ{h8FsEc3}$rk{yB=e6?j& z)y?TYp+&JL{e>bCY8>^2FeJ5=5Ev-;<;BKCqLi-bv@tQR!2~2fN5e#76{*kLZ4Ggm z9t;ljZ0+)Rv09iu7oHa+2_RGOt9L!3lGc2s{bRNve%~vikV*_%`Ss_zX_g3z^*O8I z7`d!7J*jb(ICZ;JK`Jp9b(~qtVEOadO4mhIM==*ak->fOc8k>hFpuG=#jtXVQo4zS znsUu{MHfO$^tRMvc%GjEsr~as+xBpjU6@&y_4FZLGTh%c5s?q@PL7SHs$H( zLQ88yD3rFeYxzp*>6Q)dW%P_OMDCl(?isy)_QIEKxw}7pu9p&&PFt*8MR|UBKIjE; zGWm`1*;sr(a}RBFBu?}dXidLKgw4J`rMMUFSy`!>eJ^K8253 zxCa2qRC)tyUK_6YaU!u$jYtn)TOL7^iJcQMl$|eT?CZrxR2NK|J%B(M4&2ItNk(hL zM8=0W!mwrxbpi(i`+6TtzxO4@zxqq~2$gYn1M$_k;$*ofTd(5h`_D9 zBEicFe13X4Bp+|c>{gy*l?Yk{TIeR!*h`f}?s72@Z!RV36aZ%S2Ix!kBLrE(V?fYv z{RWzxF&fDCygiKP`v;?78sp#`Y09R=-I0 z>>w6VYAIff>xo!OPz~kFzEGkR;nS`Aj8+YQu~eQP@M5{mO(EM=#ahL*E|9)Qx<8IK zwPkqI;&_%|ru{|zKxiE8yrj4`4{0squnhZKv+j~Y$JeV0F1P+`7q)w72zJvi-&!VH z_M@dyUL1Yum7Lkar#zpJ>HSDsiQs+C6mqR?a0s}8v``EXE6eEM?9`n>BrP#WfeerA zgl8!}rHEDQMnI(!%@Hgi>7V49nq{j9{hSoSUTg;#d{>bVBz8#~OgNc_xXoc@+t~6D z#VA7EMn0HFn!u`oZ0txb2*TKQAK`p(HNn=|R{XuI*Z$SjFaFKdf2OPQSF1Sy2A?o} z6#==KWlvBgo8xYk0v7FpqSPCMe#zHjj$X6huf*}bpv&h7DwIT9e-lQ;pc)DA5ELlf zUQqy4NMxXiEb^Q!+MSIq-CbW`d*#pQ`g!{V8DNsfK&veEr5hRIy>0w7eg-M`%qQ&> z(i&^y6ppvVw@Lebcp1$mb0-|TJq%Y~v zRiRW!U}r4}sO6+ITjJd{;bUB;ydCIl67xJ>rY?)jDk|%RhCp<6r23Y&Dlk@5=d!*d zfKGD+3_@#V*Bz$4DJnN_%1ORKdx`21SeTm0jb1l<6*1+V!Ujzv=Cb}p@7w5E^n0`2 ztYlpOa=el?tI9z}ZKwH6grmg075jPBMcb5Ng<(SRD-jm4<6eE{SCh@HB|273aP{2Q z7gopVKV6#D2~Y_qiZ6p&+XZFU1z``>Vj>NGlpeWJ6mj0vzC)GQeajKZk~BUueW*O4 z8#4UKXrbmEYoe3ZTwbDM>L#69520KRHb^|wr)r03Kon_QOuCZoj-*-75VZLq2he5HI_PeL?wRVu5q*mdz zbX;$w`10IUqJ1cjg}O4dD#zz)gqV8!^G-B&v@H8gs1C zss|GfGuoq}_eN_%;>-JDJ;5Jx)TL)_QWX*m3<&#g<(Tjva#Y&b(b$5F`B#QIL=4Ne z3!-+no9yT~EOo+7p%kkCyqaW|q0rE4tpM~~ z!>ENIqtbk&A;HexlJKCtpB?fdd|hj5oeOp`+rtCBZ$`?z^?eJUSs=7>@pHX$gLaj` zBw7G~yUgXdGNw9|`vd!IN^|Sq0 zglPk@!<|<{V95!{-Fwh!GjaMLaeMcoG8cXxTm$BiwrUYQB zkzGaGIm;%!;YCMcmjf7l);cGtUu({i?FfM^&Et)1(BTZDi=h_oxeA!3H2>g}XM!|f zOhtWQ-ZE%)SStr(j>Z@*s3kR61MDt{i%c(~lDCzt(2Vpy3T-0@VQUOhB$>0q2r|lX zP71bn1|e%Cg+mZ(;Nx%<;I(EzCW!T1!ItSU*0<*sV|w}1Y%E-t1<_9cK|$O#Z^%R; zdCbS)7cRvC!6g^} z>Jt3lWyzloQPNZ#mq)#oO^)F3)1+1vqQf=|Q4l(s3f2?ak%l%cd_|Z(2Bgy;o1mt? z%yvVS*Eh0vI~C(P$it+qdjHCG=%98rsRdaf0jNqg< z%U=IeMUQ%RJ+)@==Ef+RWJ`rUKgJ@w6^iKYqc<%M`nI@w_yz+a5`fXw#9&TqQyCJy z4lr{zMzh}lL6WT>MEHEbCU6(Zyi0llFUyh(3li>ILRYBlku^?T(0i*& z)$``ow`^0TqcNMhF;S&^@*i9);z&8oH++hl*$_F*HZ_be%=@wXRD$!GELG@Ft>me~ z)#EH0MMOB9rnA1cr6>cXYgLS$V-WIiL%Lb)FKW~yPP_UAoKSLt9oH1#=^80H2RmC|lR*xO2aUtzwcb&?p2S7Q{SF(lr7Bd%>oz^1p=)2>HF#)+ z$RBo`fMQ~wdys(jzLfHFe(AONCe4%EJV#D1?R)Jun?#hr4cw)w{hi1#B{w|X$n473 zYrhxm29Tbx{BlJBBG(u<0>n6nS16vzD%s&A*HNbVJ6W-~9B`RTB>bF?K9T3=*r6}d z?Chg4w<$}RxS5x2k)y(J4_$tK52rm|OWSftCy`keB5i?^2rupyFuY##p8zE*DDK(^ zw+fRL@No}Y`jTyu!<`f?5g*@@p4+|OfYUetT0@ABdKMkTQ7GKz4Waf1DE&rj=Vei#@SO&B5u7z!8@M;I(3 z7!?=}0E~BWXNAa3O6O;L_q&|piFGsp3`g?7=K(Yx0E`LpAd@4^GXTud`nA^0L~!T4 zPm0n_$^e;ri(C9e&X_#_25aj&Mk%8&Ym65F!;JjG&#aAHH1Yk;R9ccgQZUkMn(g>5u*`e0D=fz%;-XY%jG=qqp1235aF@7C{>C@`$vhOp!^ZxpM zdH$zbAa8AEW&i74!<5YJ#_t*JZoCm`PdGl&iVShd3k>YH{T&qKozNnLQ50qxvvL)c zhKNSJu|^aZ(AT&oFFWkorSsFTvbmcYHC7{LaM?)rza7+X&AXl*++Ho6E`eFCyn$iv zWG6!+i!}HmP?M&i+yms9G+sQtb^s|YIPTlT5$n*E76_a}~!4Pf7;D z;R0pmHc()ju+CJo{a=rpYYruC%&qF;qU%rRE&o8|-E-NM5*b?~KI@GE*3CWV5epm5 zAk0&c!xU2zhmd|3xB5}Ue~##`Uv)Kb$@dPS(pdG)!?x5oNRc=#>{ zA{-Nn^wlk|+D$Y_wK2mm$k9H#CNY~-+&Zp}Njv^Z!50?K!#LrhIEU@J+vZY_N?8D6 zjfKz)2dDI9xKEwub_8bU{_C}>SJ-SOZ&QbmR+_V0pl$0%6KGo?Y15q{OWcl;D>RLQ zS6nai;GX$h3ocbY$CxIIOn`@{Fnvu1+GUgL`9de1k&9m^p_^$>2oIl1)dD&V<^O7W z80q;*Og#Ga^Bq4lgVlBgx>|TbbhtK!%rm}l-lYzG@o>`TR+MUx>`9}15>D#cpMpFK zG#2yLzXOe?KnQdsNja{2Q*dX9t&6V!jqyxH^Sd+m{6<}SgXAr zUbj?4o&kNdTC-8a&m_=t+{Ms_vx$4sZB!k_r5)Z+texeAt?<9!R_kO`>kj^Qpv-ol{fpY4Oben-kg)q3;@)q%YNumyau$9|c-0 zbR^nn`Ui9Z?kf_*EVmGZAQF@>*(6h-IBhdWQ?|yWmb-pNFI9Wt33-%d(YLLX_GibM z2(*+q?`$y3 z*xyp4_xDNd#Z;LmCPyuamd)Vuf9pZHm4ROg;MtU9l-~JpaG8S?1P(EzgJo;y(v8LX z#x{eQ-&gkY1S-pA?Dk3!a|5n3zv|Y0-()?+!_rjKAWDwh*FLO&Ik#Dwku!$NdY<%d~|EI~mh z%XK;Je6*~LLd172#?>s70i78h@cCx(YOy&zS+}L-4(vM=3a9={!?po^k)z6X=o3>2!6&xaV2pa&kB*vdS{uK+m-pn_egVfLKqbCiq zvT-hbea&z&N>$6geaDw77BSvk4I7vu-TM*q){fu$Es9pSaEvtr}SJR@Hs$YZTpW}>m^CIc#LsKutu;7=p)XVx}?d2 z$HQYv2xN|DRLZ-aAP=<4sco7jBrY-CSkFSosV*a9wWIza(Wlk7`s`p#U88(n3Ss=5-<{gU3kW2- z{^R(0K{hIMcOOVtQ2(}>68uvzIauAxy1xR+QvR2m%V~ypH8NEwYO~R&(z!RUv;bG? z6C8U$xg(#yP8|Ip=E5`l5_6$6i!1B47J2N52Byz?D7_F*lFRiPKUS|~$ZijHRemRl z9QZt^$Iy`?FQ6LK#|VF__saH@&~|{?UN81@0nxD#rR4?2D3N!s_i0ikU-zg6eOB-E zoNObl3)-KQw8yuMHDUX+48z(wcpy@cR%ADm!vk*cE|Oe+)S6nA>li|^-8<_pcZu0ET{#a&$G z;eTI<|EZk@=9@(tjPtd6zXYvA=@X)k4(b{=nhy>mwvMHB4C1gzxoQA|&!vgku8iqw za5#Ss&O~pYhHaxBvvkWrVGM|^v_O1ky9Z2o;^-T{={9FY7v$9l2jAQhtJEaqfuA2? zdwKA4LR_u5m!6A~VFBK}uNf;VA{u5{rd1P#t9W6WW=%n)AzzHLku3&`GmH>*^;7<${Z4g5FXmT?nW&#wJjg!4VZ+qDP!dH1nXUlRwwEqHPBaJu-6 zX5Wn{CuBLyM<-}WqW4c(b^4LuzX*1Ro(rl%{p1*frDRSyo29So7V_$VbebZ5ZvBF> zQ)VeRW%-nuWDHj|CtK2#gobb*Bf3YN=w?b<>h@(6+YcmAK>>JhO6`T03E3V5kwjx4 zITt+Oj3li%{2Ji81yM{g>X%CDyvR~g6zn`wr)bfXcm1#etsl@_g`lJg2RaVY_}z!><%ah@q%So? zItK*%`w5O7X(e0b@=O?+PgZIUk=KvCmdvmrx6Uca#ho#2q6NN2M1iU?DQ4?NH2%nI z_~OPJqMjc*ybKimwn?jCn66|*)#N)s&tdab-Z0BpCK%K)zqVLkzDLJybSrT(wo~17 z3i`3j)!9UDIK!+dL_f_`y-}V_+0Epp7`?C8q(4{bS^>XeVjLb0E`YyQ^?Lf`bkY^8 zM7*Slu_C{K)Ir3%6;MB>%@zxyPlZ9Dm#t%oyxYYrclv2^hFIjKGSU!cE4dR!vm-J%Mft>7wNYQ j=PB&t+VS@ZF5vwR_CIj}4f8Mwl>5)S`z%5KW8wb~t)>nN literal 0 HcmV?d00001 diff --git a/internal/deps/testdata/fixture/nodebug/com/example/lib/NoDebug.java b/internal/deps/testdata/fixture/nodebug/com/example/lib/NoDebug.java new file mode 100644 index 0000000..65af392 --- /dev/null +++ b/internal/deps/testdata/fixture/nodebug/com/example/lib/NoDebug.java @@ -0,0 +1,7 @@ +package com.example.lib; + +/** Compiled without debug information: its parameters have no names. */ +public class NoDebug { + public void run(String task, int times) { + } +} diff --git a/internal/deps/testdata/fixture/sources/com/example/helper/Helper.java b/internal/deps/testdata/fixture/sources/com/example/helper/Helper.java new file mode 100644 index 0000000..808d67a --- /dev/null +++ b/internal/deps/testdata/fixture/sources/com/example/helper/Helper.java @@ -0,0 +1,8 @@ +package com.example.helper; + +/** Shipped with a sources jar: navigation opens this file. */ +public class Helper { + public static String shout(String text) { + return text.toUpperCase(); + } +} diff --git a/internal/deps/testdata/fixture/src/com/example/lib/Container.java b/internal/deps/testdata/fixture/src/com/example/lib/Container.java new file mode 100644 index 0000000..3de333c --- /dev/null +++ b/internal/deps/testdata/fixture/src/com/example/lib/Container.java @@ -0,0 +1,61 @@ +package com.example.lib; + +import java.io.Serializable; +import java.util.AbstractList; +import java.util.Collection; +import java.util.List; +import java.util.Map; + +public class Container extends AbstractList implements Serializable { + public static final int LIMIT = 10; + public static final String NAME = "box\n"; + public static final long BIG = 1L << 40; + protected Map> index; + private int hidden; + + public Container() { + } + + public Container(int capacity) { + } + + @Override + public E get(int i) { + return null; + } + + @Override + public int size() { + return hidden; + } + + public > K max(Collection all) { + return null; + } + + public Map.Entry entry(long id, double weight) { + return null; + } + + private void secret() { + } + + public class Cursor { + public Cursor(int start) { + } + + public E next() { + return null; + } + } + + public static class Builder { + public Builder add(Object o) { + return this; + } + + public Container build() { + return new Container<>(); + } + } +} diff --git a/internal/deps/testdata/fixture/src/com/example/lib/Level.java b/internal/deps/testdata/fixture/src/com/example/lib/Level.java new file mode 100644 index 0000000..d15fb58 --- /dev/null +++ b/internal/deps/testdata/fixture/src/com/example/lib/Level.java @@ -0,0 +1,28 @@ +package com.example.lib; + +public enum Level { + LOW(1) { + @Override + public String label() { + return "low"; + } + }, + HIGH(2) { + @Override + public String label() { + return "high"; + } + }; + + private final int weight; + + Level(int weight) { + this.weight = weight; + } + + public int weight() { + return weight; + } + + public abstract String label(); +} diff --git a/internal/deps/testdata/fixture/src/com/example/lib/Point.java b/internal/deps/testdata/fixture/src/com/example/lib/Point.java new file mode 100644 index 0000000..7518ae0 --- /dev/null +++ b/internal/deps/testdata/fixture/src/com/example/lib/Point.java @@ -0,0 +1,14 @@ +package com.example.lib; + +public record Point(int x, int y) implements Comparable { + public static final Point ORIGIN = new Point(0, 0); + + public double length() { + return Math.hypot(x, y); + } + + @Override + public int compareTo(Point o) { + return Integer.compare(x, o.x); + } +} diff --git a/internal/deps/testdata/fixture/src/com/example/lib/Process.java b/internal/deps/testdata/fixture/src/com/example/lib/Process.java new file mode 100644 index 0000000..a7f1518 --- /dev/null +++ b/internal/deps/testdata/fixture/src/com/example/lib/Process.java @@ -0,0 +1,5 @@ +package com.example.lib; + +/** Shares its simple name with java.lang.Process. */ +public class Process { +} diff --git a/internal/deps/testdata/fixture/src/com/example/lib/Repository.java b/internal/deps/testdata/fixture/src/com/example/lib/Repository.java new file mode 100644 index 0000000..26a24cd --- /dev/null +++ b/internal/deps/testdata/fixture/src/com/example/lib/Repository.java @@ -0,0 +1,17 @@ +package com.example.lib; + +import java.util.List; + +public interface Repository> extends Iterable { + T findById(ID id); + + List findAll(String... names); + + default boolean exists(ID id) { + return findById(id) != null; + } + + static R identity(R r) { + return r; + } +} diff --git a/internal/deps/testdata/fixture/src/com/example/lib/Runner.java b/internal/deps/testdata/fixture/src/com/example/lib/Runner.java new file mode 100644 index 0000000..189aa29 --- /dev/null +++ b/internal/deps/testdata/fixture/src/com/example/lib/Runner.java @@ -0,0 +1,11 @@ +package com.example.lib; + +public class Runner { + public java.lang.Process start(String command) { + return null; + } + + public Process local() { + return null; + } +} diff --git a/internal/deps/testdata/fixture/src/com/example/lib/Schema.java b/internal/deps/testdata/fixture/src/com/example/lib/Schema.java new file mode 100644 index 0000000..cfe57c5 --- /dev/null +++ b/internal/deps/testdata/fixture/src/com/example/lib/Schema.java @@ -0,0 +1,26 @@ +package com.example.lib; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.TYPE, ElementType.FIELD}) +public @interface Schema { + String description() default ""; + + RequiredMode requiredMode() default RequiredMode.AUTO; + + Class implementation() default Void.class; + + String[] allowableValues() default {}; + + int maxLength() default Integer.MAX_VALUE; + + enum RequiredMode { + AUTO, + REQUIRED, + NOT_REQUIRED + } +} diff --git a/internal/deps/testdata/stubs/Container.java b/internal/deps/testdata/stubs/Container.java new file mode 100644 index 0000000..9e0ce5b --- /dev/null +++ b/internal/deps/testdata/stubs/Container.java @@ -0,0 +1,30 @@ +// Signatures read from lib-1.0.jar by Java Navigation; no sources are attached. +package com.example.lib; + +import java.io.Serializable; +import java.util.AbstractList; +import java.util.Collection; +import java.util.List; +import java.util.Map; + +public class Container extends AbstractList implements Serializable { + public static final int LIMIT = 10; + public static final String NAME = "box\n"; + public static final long BIG = 1099511627776L; + protected Map> index; + public Container() { /* compiled code */ } + public Container(int capacity) { /* compiled code */ } + public E get(int i) { /* compiled code */ } + public int size() { /* compiled code */ } + public > K max(Collection all) { /* compiled code */ } + public Map.Entry entry(long id, double weight) { /* compiled code */ } + public static class Builder { + public Builder() { /* compiled code */ } + public Container.Builder add(Object o) { /* compiled code */ } + public Container build() { /* compiled code */ } + } + public class Cursor { + public Cursor(int start) { /* compiled code */ } + public E next() { /* compiled code */ } + } +} diff --git a/internal/deps/testdata/stubs/Level.java b/internal/deps/testdata/stubs/Level.java new file mode 100644 index 0000000..1fd1500 --- /dev/null +++ b/internal/deps/testdata/stubs/Level.java @@ -0,0 +1,9 @@ +// Signatures read from lib-1.0.jar by Java Navigation; no sources are attached. +package com.example.lib; + +public enum Level { + LOW, + HIGH; + public int weight() { /* compiled code */ } + public abstract String label(); +} diff --git a/internal/deps/testdata/stubs/NoDebug.java b/internal/deps/testdata/stubs/NoDebug.java new file mode 100644 index 0000000..4041a1c --- /dev/null +++ b/internal/deps/testdata/stubs/NoDebug.java @@ -0,0 +1,7 @@ +// Signatures read from lib-1.0.jar by Java Navigation; no sources are attached. +package com.example.lib; + +public class NoDebug { + public NoDebug() { /* compiled code */ } + public void run(String arg0, int arg1) { /* compiled code */ } +} diff --git a/internal/deps/testdata/stubs/Point.java b/internal/deps/testdata/stubs/Point.java new file mode 100644 index 0000000..2152d48 --- /dev/null +++ b/internal/deps/testdata/stubs/Point.java @@ -0,0 +1,8 @@ +// Signatures read from lib-1.0.jar by Java Navigation; no sources are attached. +package com.example.lib; + +public record Point(int x, int y) implements Comparable { + public static final Point ORIGIN; + public double length() { /* compiled code */ } + public int compareTo(Point o) { /* compiled code */ } +} diff --git a/internal/deps/testdata/stubs/Process.java b/internal/deps/testdata/stubs/Process.java new file mode 100644 index 0000000..223b140 --- /dev/null +++ b/internal/deps/testdata/stubs/Process.java @@ -0,0 +1,6 @@ +// Signatures read from lib-1.0.jar by Java Navigation; no sources are attached. +package com.example.lib; + +public class Process { + public Process() { /* compiled code */ } +} diff --git a/internal/deps/testdata/stubs/Repository.java b/internal/deps/testdata/stubs/Repository.java new file mode 100644 index 0000000..cac4c48 --- /dev/null +++ b/internal/deps/testdata/stubs/Repository.java @@ -0,0 +1,11 @@ +// Signatures read from lib-1.0.jar by Java Navigation; no sources are attached. +package com.example.lib; + +import java.util.List; + +public interface Repository> extends Iterable { + T findById(ID arg0); + List findAll(String... arg0); + default boolean exists(ID id) { /* compiled code */ } + static R identity(R r) { /* compiled code */ } +} diff --git a/internal/deps/testdata/stubs/Runner.java b/internal/deps/testdata/stubs/Runner.java new file mode 100644 index 0000000..d8196fe --- /dev/null +++ b/internal/deps/testdata/stubs/Runner.java @@ -0,0 +1,8 @@ +// Signatures read from lib-1.0.jar by Java Navigation; no sources are attached. +package com.example.lib; + +public class Runner { + public Runner() { /* compiled code */ } + public java.lang.Process start(String command) { /* compiled code */ } + public Process local() { /* compiled code */ } +} diff --git a/internal/deps/testdata/stubs/Schema.java b/internal/deps/testdata/stubs/Schema.java new file mode 100644 index 0000000..5f58fe7 --- /dev/null +++ b/internal/deps/testdata/stubs/Schema.java @@ -0,0 +1,15 @@ +// Signatures read from lib-1.0.jar by Java Navigation; no sources are attached. +package com.example.lib; + +public @interface Schema { + String description() default ""; + Schema.RequiredMode requiredMode() default Schema.RequiredMode.AUTO; + Class implementation() default Void.class; + String[] allowableValues() default {}; + int maxLength() default 2147483647; + public enum RequiredMode { + AUTO, + REQUIRED, + NOT_REQUIRED; + } +} diff --git a/internal/deps/version.go b/internal/deps/version.go new file mode 100644 index 0000000..10eebd5 --- /dev/null +++ b/internal/deps/version.go @@ -0,0 +1,110 @@ +package deps + +import ( + "strconv" + "strings" +) + +// compareVersions orders Maven versions closely enough to pick the newest +// one in a local repository: numeric parts compare as numbers, and a +// pre-release qualifier (alpha, beta, milestone, rc, snapshot) sorts before +// the release it precedes, which equals ga, final and release. Unknown +// qualifiers sort after the release, lexically. +func compareVersions(a, b string) int { + ta, tb := versionTokens(a), versionTokens(b) + for i := 0; i < len(ta) || i < len(tb); i++ { + var x, y vtoken + if i < len(ta) { + x = ta[i] + } else { + x = padding(tb[i]) + } + if i < len(tb) { + y = tb[i] + } else { + y = padding(ta[i]) + } + if c := x.compare(y); c != 0 { + return c + } + } + return 0 +} + +type vtoken struct { + num int64 + rank int // for qualifiers: see qualifierRank; numbers are rank 100 + text string +} + +const numberRank = 100 + +var qualifierRank = map[string]int{"alpha": 1, "a": 1, "beta": 2, "b": 2, "milestone": 3, "m": 3, + "rc": 4, "cr": 4, "snapshot": 5, "": 6, "ga": 6, "final": 6, "release": 6, "sp": 7} + +// padding stands in for a missing token: zero against a number, the +// release against a qualifier. +func padding(other vtoken) vtoken { + if other.rank == numberRank { + return vtoken{rank: numberRank} + } + return vtoken{rank: 6} +} + +func (x vtoken) compare(y vtoken) int { + if x.rank != y.rank { + if x.rank < y.rank { + return -1 + } + return 1 + } + if x.rank == numberRank { + switch { + case x.num < y.num: + return -1 + case x.num > y.num: + return 1 + } + return 0 + } + return strings.Compare(x.text, y.text) +} + +func versionTokens(v string) []vtoken { + var out []vtoken + v = strings.ToLower(v) + i := 0 + for i < len(v) { + c := v[i] + if c == '.' || c == '-' || c == '_' { + i++ + continue + } + j := i + digit := c >= '0' && c <= '9' + for j < len(v) && v[j] != '.' && v[j] != '-' && v[j] != '_' && (v[j] >= '0' && v[j] <= '9') == digit { + j++ + } + s := v[i:j] + if digit { + n, _ := strconv.ParseInt(s, 10, 64) + out = append(out, vtoken{num: n, rank: numberRank}) + } else if r, ok := qualifierRank[s]; ok { + out = append(out, vtoken{rank: r, text: s}) + } else { + out = append(out, vtoken{rank: 8, text: s}) + } + i = j + } + // Trailing zeros and release qualifiers do not make a version newer: + // 1.0 == 1 == 1.0.0.RELEASE. + for len(out) > 0 { + t := out[len(out)-1] + if t.rank == numberRank && t.num == 0 || t.rank == 6 { + out = out[:len(out)-1] + continue + } + break + } + return out +} diff --git a/internal/jdk/sources.go b/internal/jdk/sources.go index 94c261f..37db6a0 100644 --- a/internal/jdk/sources.go +++ b/internal/jdk/sources.go @@ -40,7 +40,7 @@ type Sources struct { size int64 mod time.Time entries map[string]entry // by entry name, .java files only - lib *codenav.Library + names []string // the entry names, in archive order } // entry locates one stored or deflated file in the archive. @@ -82,12 +82,13 @@ func Open(j JDK) (*Sources, error) { if len(names) == 0 { return nil, fmt.Errorf("read JDK sources: no Java sources in %s", j.Sources) } - s.lib = codenav.NewLibrary(names, s.Read) + s.names = names return s, nil } -// Library is the codenav library over these sources. -func (s *Sources) Library() *codenav.Library { return s.lib } +// Names returns the listed Java sources' entry names, in archive order, for +// a codenav library that reads them with Read. +func (s *Sources) Names() []string { return s.names } // Version is the JDK's JAVA_VERSION, "" when unknown. func (s *Sources) Version() string { return s.version } diff --git a/internal/lsp/server.go b/internal/lsp/server.go index 311f37a..3688e73 100644 --- a/internal/lsp/server.go +++ b/internal/lsp/server.go @@ -27,6 +27,9 @@ type Options struct { State string // FindJDK locates the JDK whose sources are used; nil disables them. FindJDK func(context.Context) (jdk.JDK, bool) + // MavenRepo is the local Maven repository dependency jars are read + // from; "" disables them. + MavenRepo string // Workspace overrides workspace options other than Root, for tests. Workspace workspace.Options // Logf writes a diagnostic line; nil discards it. @@ -305,6 +308,7 @@ func (s *Server) newWorkspace(root string) *workspace.Workspace { o.Root = root o.State = s.opt.State o.FindJDK = s.opt.FindJDK + o.MavenRepo = s.opt.MavenRepo if o.Logf == nil { o.Logf = s.opt.Logf } diff --git a/internal/workspace/deps_test.go b/internal/workspace/deps_test.go new file mode 100644 index 0000000..e5270ab --- /dev/null +++ b/internal/workspace/deps_test.go @@ -0,0 +1,237 @@ +package workspace + +import ( + "crypto/sha256" + "io" + "os" + "path/filepath" + "strings" + "testing" +) + +// mavenRepo is a local repository holding the deps package's fixture jars: +// com.example:lib:1.0 (class files only) and com.example:helper:2.0 (with a +// sources jar). +func mavenRepo(t *testing.T) string { + t.Helper() + root := t.TempDir() + fixture := filepath.Join("..", "deps", "testdata", "fixture") + for _, a := range []struct{ artifact, version, file string }{ + {"lib", "1.0", "lib-1.0.jar"}, + {"helper", "2.0", "helper-2.0.jar"}, + {"helper", "2.0", "helper-2.0-sources.jar"}, + } { + dir := filepath.Join(root, "com", "example", a.artifact, a.version) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + copyFile(t, filepath.Join(fixture, a.file), filepath.Join(dir, a.file)) + pom := "com.example" + a.artifact + "" + a.version + "" + if err := os.WriteFile(filepath.Join(dir, a.artifact+"-"+a.version+".pom"), []byte(pom), 0o644); err != nil { + t.Fatal(err) + } + } + return root +} + +func copyFile(t *testing.T, from, to string) { + t.Helper() + in, err := os.Open(from) + if err != nil { + t.Fatal(err) + } + defer in.Close() + out, err := os.Create(to) + if err != nil { + t.Fatal(err) + } + if _, err := io.Copy(out, in); err != nil { + t.Fatal(err) + } + if err := out.Close(); err != nil { + t.Fatal(err) + } +} + +// treeHash hashes every file under root, to show a run left it unchanged. +func treeHash(t *testing.T, root string) [32]byte { + t.Helper() + h := sha256.New() + err := filepath.Walk(root, func(p string, fi os.FileInfo, err error) error { + if err != nil || fi.IsDir() { + return err + } + b, err := os.ReadFile(p) + if err != nil { + return err + } + io.WriteString(h, p+"\x00") + h.Write(b) + return nil + }) + if err != nil { + t.Fatal(err) + } + var out [32]byte + copy(out[:], h.Sum(nil)) + return out +} + +const depsPOM = `com.acmeapp1 + + com.examplelib1.0 +` + +const useDeps = `package com.acme; + +import com.example.lib.Container; +import com.example.lib.Schema; + +public class UseDeps { + @Schema(description = "a box", requiredMode = Schema.RequiredMode.REQUIRED) + private Container box = new Container.Builder().add("x").build(); + + int size() { + return box.size(); + } +} +` + +// Names from a Maven dependency open a stub of its class files, and +// navigation continues inside the stub; the local repository is never +// written. +func TestDefinitionIntoADependencyJarAndBack(t *testing.T) { + root := t.TempDir() + write(t, root, map[string]string{"pom.xml": depsPOM, "src/main/java/com/acme/UseDeps.java": useDeps}) + repo := mavenRepo(t) + before := treeHash(t, repo) + w := New(Options{Root: root, State: filepath.Join(t.TempDir(), "state"), MavenRepo: repo, + ListFiles: walkFiles, Freshness: -1, Logf: t.Logf}) + doc := w.DocOf(filepath.Join(root, "src/main/java/com/acme/UseDeps.java")) + + def := func(d Doc, text, needle string, n, skip int) Target { + t.Helper() + tgt, ok, err := w.Definition(ctx(t), d, []byte(text), at(t, text, needle, n, skip)) + if err != nil || !ok { + t.Fatalf("%q: %v %v", needle, ok, err) + } + return tgt + } + line := func(tgt Target) string { + t.Helper() + b, err := os.ReadFile(tgt.File) + if err != nil { + t.Fatal(err) + } + return strings.TrimSpace(strings.Split(string(b), "\n")[tgt.Line]) + } + for _, c := range []struct { + needle string + n int + want string + }{ + {"Schema(", 0, "public @interface Schema {"}, + {"description =", 0, `String description() default "";`}, + {"RequiredMode.REQUIRED", 0, "public enum RequiredMode {"}, + {"REQUIRED)", 0, "REQUIRED,"}, + {"Container", 0, "public class Container extends AbstractList implements Serializable {"}, + {"Builder()", 0, "public Builder() { /* compiled code */ }"}, + {"add(", 0, "public Container.Builder add(Object o) { /* compiled code */ }"}, + {"build()", 0, "public Container build() { /* compiled code */ }"}, + {"size();", 0, "public int size() { /* compiled code */ }"}, + } { + tgt := def(doc, useDeps, c.needle, c.n, 1) + if !strings.Contains(filepath.ToSlash(tgt.File), "/state/dependency-sources/com.example_lib_1.0-") { + t.Errorf("%q opened %s", c.needle, tgt.File) + } + if got := line(tgt); got != c.want { + t.Errorf("%q landed on %q, want %q", c.needle, got, c.want) + } + } + + // The stub is a library source: navigation continues inside it, into + // another class of the jar and into the JDK-less java.util it names + // (which resolves to nothing without a JDK). + stub := def(doc, useDeps, "Container", 0, 1) + sdoc := w.DocOf(stub.File) + if sdoc.Library == "" || sdoc.Rel != "" { + t.Fatalf("DocOf(stub) = %+v", sdoc) + } + text, _ := os.ReadFile(stub.File) + inner := def(sdoc, string(text), "Container.Builder add", 0, 10) + if got := line(inner); got != "public static class Builder {" { + t.Errorf("Builder from inside the stub landed on %q", got) + } + // Usages of a dependency's declaration are the working tree's. + refs, _, err := w.References(ctx(t), doc, []byte(useDeps), at(t, useDeps, "size();", 0, 1), false) + if err != nil || len(refs) != 1 || refs[0].File != filepath.Join(root, "src/main/java/com/acme/UseDeps.java") { + t.Errorf("references of Container.size = %+v %v", refs, err) + } + if treeHash(t, repo) != before { + t.Errorf("the local repository changed") + } +} + +// A dependency added to pom.xml is found by the next scan, and a dependency +// with a sources jar opens its source. +func TestEditingPOMRereadsTheDependencies(t *testing.T) { + root := t.TempDir() + src := "package com.acme;\n\nimport com.example.helper.Helper;\n\nclass A {\n String s = Helper.shout(\"x\");\n}\n" + write(t, root, map[string]string{"pom.xml": depsPOM, "src/main/java/com/acme/A.java": src}) + w := New(Options{Root: root, State: filepath.Join(t.TempDir(), "state"), MavenRepo: mavenRepo(t), + ListFiles: walkFiles, Freshness: -1, Logf: t.Logf}) + doc := w.DocOf(filepath.Join(root, "src/main/java/com/acme/A.java")) + if _, ok, err := w.Definition(ctx(t), doc, []byte(src), at(t, src, "shout", 0, 1)); err != nil || ok { + t.Fatalf("Helper resolved before the POM declared it: %v %v", ok, err) + } + pom := strings.Replace(depsPOM, "", + " com.examplehelper2.0\n", 1) + // A size change is enough for the scan to notice, whatever the clock's + // resolution. + write(t, root, map[string]string{"pom.xml": pom}) + tgt, ok, err := w.Definition(ctx(t), doc, []byte(src), at(t, src, "shout", 0, 1)) + if err != nil || !ok { + t.Fatalf("Helper.shout after the POM changed: %v %v", ok, err) + } + b, err := os.ReadFile(tgt.File) + if err != nil { + t.Fatal(err) + } + if lines := strings.Split(string(b), "\n"); !strings.Contains(lines[tgt.Line], "public static String shout(String text) {") || + !strings.Contains(string(b), "return text.toUpperCase();") { + t.Errorf("shout opened line %d of\n%s", tgt.Line, b) + } +} + +// Sources a build generated into a module's output directory are listed, +// though .gitignore and the walk leave target/ out. +func TestGeneratedSourcesAreListed(t *testing.T) { + root := t.TempDir() + write(t, root, map[string]string{ + "pom.xml": "", + "api/pom.xml": "", + "api/target/generated-sources/protobuf/java/a/Msg.java": "package a; public class Msg {}", + "api/target/classes/a/Msg.class": "", + "api/target/other/B.java": "package b; class B {}", + "web/build.gradle": "", + "web/build/generated/sources/annotationProcessor/java/main/w/Gen.java": "package w; class Gen {}", + "src/main/java/a/Use.java": "package a; class Use { Msg m; }", + }) + files, err := ListFiles(ctx(t), root) + if err != nil { + t.Fatal(err) + } + got := map[string]bool{} + for _, f := range files { + got[f] = true + } + for _, want := range []string{"api/target/generated-sources/protobuf/java/a/Msg.java", + "web/build/generated/sources/annotationProcessor/java/main/w/Gen.java", "src/main/java/a/Use.java"} { + if !got[want] { + t.Errorf("%s not listed in %v", want, files) + } + } + if got["api/target/other/B.java"] || got["api/target/classes/a/Msg.class"] { + t.Errorf("listed other build output: %v", files) + } +} diff --git a/internal/workspace/files.go b/internal/workspace/files.go index 2482413..8a46eeb 100644 --- a/internal/workspace/files.go +++ b/internal/workspace/files.go @@ -4,7 +4,9 @@ import ( "bytes" "context" "io/fs" + "os" "os/exec" + "path" "path/filepath" "strings" "time" @@ -23,12 +25,70 @@ var skippedDirs = map[string]bool{ // Inside a Git working tree it asks Git for the tracked and untracked files, // leaving out what .gitignore ignores, as Viff does. Elsewhere, or when Git // is not installed, it walks root, skipping hidden directories and the usual -// build output directories. +// build output directories. Either way the sources a build generated into a +// module's output directory are added (see generatedDirs). func ListFiles(ctx context.Context, root string) ([]string, error) { - if files, ok := gitFiles(ctx, root); ok { - return files, nil + files, ok := gitFiles(ctx, root) + if !ok { + var err error + if files, err = walkFiles(ctx, root); err != nil { + return nil, err + } + } + return append(files, generatedFiles(ctx, root, files)...), nil +} + +// generatedDirs are where Maven (protobuf, annotation processors, OpenAPI) +// and Gradle write generated sources, relative to the directory of a +// module's build file. The project's code compiles against them, as JDT LS +// sees them, but .gitignore leaves them out with the rest of the output. +var generatedDirs = map[string][]string{ + "pom.xml": {"target/generated-sources", "target/generated-test-sources"}, + "build.gradle": {"build/generated"}, + "build.gradle.kts": {"build/generated"}, +} + +// maxGenerated bounds the generated files one listing adds. +const maxGenerated = 50000 + +// generatedFiles lists the Java files under the generated source directories +// of the modules whose build files are among files, skipping those already +// listed. +func generatedFiles(ctx context.Context, root string, files []string) []string { + listed := map[string]bool{} + var dirs []string + for _, f := range files { + listed[f] = true + dir, base := path.Split(f) + for _, g := range generatedDirs[base] { + dirs = append(dirs, dir+g) + } + } + var out []string + for _, d := range dirs { + full := filepath.Join(root, filepath.FromSlash(d)) + if fi, err := os.Stat(full); err != nil || !fi.IsDir() { + continue + } + filepath.WalkDir(full, func(p string, e fs.DirEntry, err error) error { + if err != nil || ctx.Err() != nil || len(out) >= maxGenerated { + return filepath.SkipDir + } + if e.IsDir() || !e.Type().IsRegular() || !strings.HasSuffix(e.Name(), ".java") { + return nil + } + rel, err := filepath.Rel(root, p) + if err != nil { + return nil + } + if r := filepath.ToSlash(rel); !listed[r] { + listed[r] = true + out = append(out, r) + } + return nil + }) } - return walkFiles(ctx, root) + return out } // gitFiles lists root's files through Git; ok is false when root is not in a diff --git a/internal/workspace/workspace.go b/internal/workspace/workspace.go index e078df8..425093b 100644 --- a/internal/workspace/workspace.go +++ b/internal/workspace/workspace.go @@ -14,25 +14,30 @@ // text again. // // Names the folder does not declare resolve into the JDK's sources when a JDK -// with sources is installed. Its declarations are extracted, one file at a -// time, into read-only copies under the state directory, which the editor -// opens as ordinary files; a query from such a copy is answered as a JDK -// source. +// with sources is installed, and into the jars of the folder's Maven +// dependencies that the local repository holds (package deps). Their +// declarations are extracted, one file at a time, into read-only copies +// under the state directory, which the editor opens as ordinary files; a +// query from such a copy is answered as a library source. package workspace import ( "context" "errors" "fmt" + "io" "os" + "path" "path/filepath" "runtime" + "runtime/debug" "sort" "strings" "sync" "time" "github.com/Lithe-IDEA/lithe-plugin-java-navigation/internal/codenav" + "github.com/Lithe-IDEA/lithe-plugin-java-navigation/internal/deps" "github.com/Lithe-IDEA/lithe-plugin-java-navigation/internal/jdk" ) @@ -47,6 +52,13 @@ const ( DefaultFreshness = 2 * time.Second // MaxReferences bounds a reference list. MaxReferences = 5000 + // releaseAfter is the number of files a scan parses before the memory + // it allocated is returned to the system right away. + releaseAfter = 500 + // depsRefresh is how long the dependency jars are used before a scan + // reads the build files and the local repository again, so jars a build + // downloaded meanwhile are found without a change to a pom.xml. + depsRefresh = 5 * time.Minute ) // Options configure a Workspace. @@ -58,6 +70,9 @@ type Options struct { State string // FindJDK locates the JDK whose sources are used; nil disables them. FindJDK func(context.Context) (jdk.JDK, bool) + // MavenRepo is the local Maven repository whose jars the folder's + // dependencies are read from; "" (or an empty State) disables them. + MavenRepo string // ListFiles lists the files of root as slash-separated relative paths; // nil uses ListFiles. ListFiles func(ctx context.Context, root string) ([]string, error) @@ -68,11 +83,13 @@ type Options struct { Logf func(format string, args ...any) } -// Doc names a document: a file of the workspace (Rel set), a JDK source +// Doc names a document: a file of the workspace (Rel set), a library source // (Library set) or a file navigation does not cover (neither). type Doc struct { - Rel string // slash-separated path relative to Root - Library string // the JDK entry name, for an extracted copy + Rel string // slash-separated path relative to Root + // Library is the library path of an extracted copy: a JDK entry name, + // or a dependency's "!a/b/C.java" (see package deps). + Library string } // Covered reports whether navigation answers queries from d. @@ -101,7 +118,11 @@ type Workspace struct { scan *scanRun // the scan in flight, nil when idle stale bool // a change was reported after scannedAt sources *jdk.Sources - looked bool // the JDK was looked for + looked bool // the JDK was looked for + deps *deps.Set // the dependency jars, nil until listed + poms map[string]stamp // the pom.xml files deps was read from + depsAt time.Time // when deps was read + lib *codenav.Library // the JDK and deps, as the index looks names up } type stamp struct { @@ -141,13 +162,18 @@ func (w *Workspace) DocOf(file string) Doc { return Doc{} } w.mu.Lock() - s := w.sources + s, ds := w.sources, w.deps w.mu.Unlock() if s != nil && w.opt.State != "" { if name, ok := s.EntryOf(w.opt.State, file); ok { return Doc{Library: name} } } + if ds != nil && w.opt.State != "" { + if p, ok := ds.PathOf(w.opt.State, file); ok { + return Doc{Library: p} + } + } rel, err := filepath.Rel(w.opt.Root, file) if err != nil || rel == "." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || rel == ".." || filepath.IsAbs(rel) { return Doc{} @@ -282,10 +308,10 @@ func (w *Workspace) resolve(d Doc, text []byte, off int) (codenav.Location, bool return loc, true, nil } w.mu.Lock() - s := w.sources + lib := w.lib w.mu.Unlock() - if s != nil { - if err := s.Library().Err(); err != nil { + if lib != nil { + if err := lib.Err(); err != nil { return loc, false, fmt.Errorf("go to definition: %w", err) } } @@ -300,9 +326,23 @@ func (w *Workspace) target(l codenav.Location) (Target, error) { return t, nil } w.mu.Lock() - s := w.sources + s, ds := w.sources, w.deps w.mu.Unlock() - if s == nil || w.opt.State == "" { + if w.opt.State == "" { + return Target{}, errors.New("no state directory for library sources") + } + if strings.Contains(l.Path, deps.Sep) { + if ds == nil { + return Target{}, errors.New("no dependency jars") + } + file, err := ds.Extract(w.opt.State, l.Path) + if err != nil { + return Target{}, err + } + t.File = file + return t, nil + } + if s == nil { return Target{}, errors.New("no JDK sources") } file, err := s.Extract(w.opt.State, l.Path) @@ -419,6 +459,8 @@ func (w *Workspace) runScan(run *scanRun) { started := time.Now() w.mu.Lock() old := w.stamps + oldPoms := w.poms + depsAt := w.depsAt w.stale = false lookJDK := w.opt.FindJDK != nil && (!w.looked || w.sources != nil && w.sources.Changed()) if lookJDK { @@ -426,8 +468,8 @@ func (w *Workspace) runScan(run *scanRun) { } w.mu.Unlock() - // Listing src.zip takes about as long as a first scan (Viff ADR 0041), - // so the two run side by side. + // Listing src.zip and the dependency jars takes about as long as a first + // scan (Viff ADR 0041), so they run side by side with it. var found *jdk.Sources var jdkErr error listed := make(chan struct{}) @@ -441,23 +483,56 @@ func (w *Workspace) runScan(run *scanRun) { } else { close(listed) } - res, err := w.scanFiles(ctx, old) + all, err := w.opt.ListFiles(ctx, w.opt.Root) + if err != nil { + err = fmt.Errorf("list source files: %w", err) + } + poms := w.pomStamps(all) + lookDeps := err == nil && w.opt.MavenRepo != "" && w.opt.State != "" && + (oldPoms == nil || !sameStamps(oldPoms, poms) || time.Since(depsAt) > depsRefresh) + var depSet *deps.Set + var depsErr error + depsDone := make(chan struct{}) + if lookDeps { + go func() { + defer close(depsDone) + depSet, depsErr = w.readDeps(ctx, poms) + }() + } else { + close(depsDone) + } + var res scanResult + if err == nil { + res, err = w.scanFiles(ctx, all, old) + } <-listed + <-depsDone w.mu.Lock() + changed := false if lookJDK { if jdkErr != nil { w.opt.Logf("JDK sources unavailable: %v", jdkErr) } if found != nil || jdkErr == nil { w.sources = found + changed = true } - var lib *codenav.Library if w.sources != nil { - lib = w.sources.Library() w.opt.Logf("JDK sources: %s (%s)", w.sources.Archive(), w.sources.Version()) } - w.ix.SetLibrary(lib) + } + if lookDeps { + if depsErr != nil { + w.opt.Logf("dependency jars unavailable: %v", depsErr) + } else { + w.deps, w.poms, w.depsAt = depSet, poms, started + changed = true + } + } + if changed { + w.lib = w.library() + w.ix.SetLibrary(w.lib) } if err == nil { for _, p := range res.removed { @@ -478,6 +553,82 @@ func (w *Workspace) runScan(run *scanRun) { w.scan = nil w.mu.Unlock() close(run.done) + if len(res.parsed) >= releaseAfter || lookDeps { + // Indexing a tree allocates several times the index it leaves; the + // runtime would keep those pages and return them only slowly, so a + // first scan of a large tree would hold about three times the + // memory the index needs for minutes. Returning them now takes one + // collection, after the waiting queries were released. + debug.FreeOSMemory() + } +} + +// library is the codenav library over the JDK's sources and the dependency +// jars, the JDK first so a jar that bundles JDK classes never shadows them. +// The caller holds w.mu. +func (w *Workspace) library() *codenav.Library { + var paths []string + s, ds := w.sources, w.deps + if s != nil { + paths = append(paths, s.Names()...) + } + if ds != nil { + paths = append(paths, ds.Paths()...) + } + if len(paths) == 0 { + return nil + } + return codenav.NewLibrary(paths, func(p string) ([]byte, error) { + if strings.Contains(p, deps.Sep) { + return ds.Read(p) + } + return s.Read(p) + }) +} + +// pomStamps returns the size and modification time of the folder's +// pom.xml files among all, the folder's files. +func (w *Workspace) pomStamps(all []string) map[string]stamp { + out := map[string]stamp{} + for _, p := range all { + if path.Base(p) != "pom.xml" { + continue + } + if fi, err := os.Stat(w.abs(p)); err == nil && fi.Mode().IsRegular() { + out[p] = stamp{size: fi.Size(), mod: fi.ModTime()} + } + } + return out +} + +func sameStamps(a, b map[string]stamp) bool { + if len(a) != len(b) { + return false + } + for k, v := range a { + if o, ok := b[k]; !ok || !o.same(v) { + return false + } + } + return true +} + +// readDeps lists the jars of the Maven dependencies the poms declare. +func (w *Workspace) readDeps(ctx context.Context, poms map[string]stamp) (*deps.Set, error) { + if len(poms) == 0 { + return deps.Open(nil, w.opt.Logf), nil + } + files := make([]string, 0, len(poms)) + for p := range poms { + files = append(files, w.abs(p)) + } + jars, err := deps.MavenJars(ctx, files, w.opt.MavenRepo, w.opt.Logf) + if err != nil { + return nil, err + } + set := deps.Open(jars, w.opt.Logf) + w.opt.Logf("dependency jars: %d of %d resolved, %d classes", set.Len(), len(jars), set.Classes()) + return set, nil } // scanResult is what changed since the previous scan. @@ -487,14 +638,10 @@ type scanResult struct { stamps map[string]stamp } -// scanFiles lists the folder's Java files and parses those that are new or -// changed since old. Files that disappeared, became unreadable or grew past -// MaxFileSize are reported as removed. -func (w *Workspace) scanFiles(ctx context.Context, old map[string]stamp) (scanResult, error) { - all, err := w.opt.ListFiles(ctx, w.opt.Root) - if err != nil { - return scanResult{}, fmt.Errorf("list source files: %w", err) - } +// scanFiles parses the Java files among all, the folder's files, that are new +// or changed since old. Files that disappeared, became unreadable or grew +// past MaxFileSize are reported as removed. +func (w *Workspace) scanFiles(ctx context.Context, all []string, old map[string]stamp) (scanResult, error) { var paths []string for _, p := range all { if codenav.Supports(p) { @@ -511,6 +658,10 @@ func (w *Workspace) scanFiles(ctx context.Context, old map[string]stamp) (scanRe wg.Add(1) go func() { defer wg.Done() + // The file's bytes and tokens are scratch once its declarations + // are read, so each worker reuses one buffer of each. + var fp codenav.FileParser + var buf []byte for i := range next { p := paths[i] full := w.abs(p) @@ -523,12 +674,13 @@ func (w *Workspace) scanFiles(ctx context.Context, old map[string]stamp) (scanRe stamps[i], indexed[i] = st, true continue } - src, err := os.ReadFile(full) + src, err := readInto(buf, full) if err != nil { continue } + buf = src stamps[i], indexed[i] = st, true - parsed[i] = codenav.ParseFile(p, src) + parsed[i] = fp.Parse(p, src) } }() } @@ -562,3 +714,31 @@ func (w *Workspace) scanFiles(ctx context.Context, old map[string]stamp) (scanRe } func workers() int { return min(runtime.GOMAXPROCS(0), 8) } + +// readInto reads the file at path into buf, growing it when the file does +// not fit, and returns the bytes read. A file that grew past MaxFileSize +// since it was listed is refused. +func readInto(buf []byte, path string) ([]byte, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + buf = buf[:0] + for { + if len(buf) == cap(buf) { + buf = append(buf, 0)[:len(buf)] + } + n, err := f.Read(buf[len(buf):cap(buf)]) + buf = buf[:len(buf)+n] + if len(buf) > MaxFileSize { + return nil, fmt.Errorf("%s: larger than %d bytes", path, MaxFileSize) + } + if err == io.EOF { + return buf, nil + } + if err != nil { + return nil, err + } + } +} From 3556dc774be58a42271b8dbc82d1dc19082cce1b Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Thu, 12 Nov 2026 19:44:15 -0800 Subject: [PATCH 2/2] test: keep the deps test data byte-exact on Windows checkouts Co-Authored-By: Claude Opus 5.5 --- .gitattributes | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..cd0c6dd --- /dev/null +++ b/.gitattributes @@ -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