From 503e5d695df9e533420a7275fdc637460a56a7d9 Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Thu, 12 Nov 2026 03:41:22 -0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=B8=8D=E4=BE=9D=E8=B5=96=20JDT=20LS?= =?UTF-8?q?=20=E7=9A=84=E8=BD=BB=E9=87=8F=20Java=20=E8=B7=B3=E8=BD=AC?= =?UTF-8?q?=E6=8F=92=E4=BB=B6=200.0.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 语言服务器(Go):跳转到定义、查找引用、文档结构,stdio LSP,支持 UTF-8/UTF-16 位置编码与增量文档同步。 - 解析规则复用 Viff 的 codenav(MIT),原样拷贝,Lithe 新增的 symbols.go 单独成文件。 - 跳进本机 JDK 的 src.zip,按需解压成只读副本放在 ${stateDir}。 - 按平台交叉编译打包(darwin-arm64/x64、windows-x64/arm64),按插件契约 v0 声明 packaged 语言服务器。 - CI(Linux 全量 + Windows 测试、打包与 Store 规则校验)与发布工作流。 Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 62 + .github/workflows/release.yml | 66 + README.md | 95 +- THIRD_PARTY_NOTICES.md | 37 + cmd/java-navigation/main.go | 49 + docs/releases/v0.0.1.md | 19 + go.mod | 3 + internal/codenav/UPSTREAM.md | 20 + internal/codenav/codenav_test.go | 373 ++++++ internal/codenav/doc.go | 22 + internal/codenav/index.go | 152 +++ internal/codenav/lex.go | 145 +++ internal/codenav/library.go | 166 +++ internal/codenav/library_test.go | 360 ++++++ internal/codenav/parse.go | 719 +++++++++++ internal/codenav/resolve.go | 1117 +++++++++++++++++ internal/codenav/symbols.go | 102 ++ internal/codenav/usages.go | 189 +++ internal/codenav/usages_test.go | 187 +++ internal/jdk/find.go | 208 +++ internal/jdk/find_test.go | 138 ++ internal/jdk/sources.go | 228 ++++ internal/jdk/sources_test.go | 127 ++ internal/lsp/documents.go | 40 + internal/lsp/jsonrpc.go | 138 ++ internal/lsp/position.go | 154 +++ internal/lsp/position_test.go | 98 ++ internal/lsp/server.go | 707 +++++++++++ internal/lsp/server_test.go | 253 ++++ internal/lsp/uri.go | 48 + internal/workspace/files.go | 93 ++ internal/workspace/hidewindow_other.go | 7 + internal/workspace/hidewindow_windows.go | 14 + internal/workspace/workspace.go | 564 +++++++++ internal/workspace/workspace_test.go | 284 +++++ .../plugin.json | 55 + scripts/package.mjs | 84 ++ 37 files changed, 7122 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release.yml create mode 100644 THIRD_PARTY_NOTICES.md create mode 100644 cmd/java-navigation/main.go create mode 100644 docs/releases/v0.0.1.md create mode 100644 go.mod create mode 100644 internal/codenav/UPSTREAM.md create mode 100644 internal/codenav/codenav_test.go create mode 100644 internal/codenav/doc.go create mode 100644 internal/codenav/index.go create mode 100644 internal/codenav/lex.go create mode 100644 internal/codenav/library.go create mode 100644 internal/codenav/library_test.go create mode 100644 internal/codenav/parse.go create mode 100644 internal/codenav/resolve.go create mode 100644 internal/codenav/symbols.go create mode 100644 internal/codenav/usages.go create mode 100644 internal/codenav/usages_test.go create mode 100644 internal/jdk/find.go create mode 100644 internal/jdk/find_test.go create mode 100644 internal/jdk/sources.go create mode 100644 internal/jdk/sources_test.go create mode 100644 internal/lsp/documents.go create mode 100644 internal/lsp/jsonrpc.go create mode 100644 internal/lsp/position.go create mode 100644 internal/lsp/position_test.go create mode 100644 internal/lsp/server.go create mode 100644 internal/lsp/server_test.go create mode 100644 internal/lsp/uri.go create mode 100644 internal/workspace/files.go create mode 100644 internal/workspace/hidewindow_other.go create mode 100644 internal/workspace/hidewindow_windows.go create mode 100644 internal/workspace/workspace.go create mode 100644 internal/workspace/workspace_test.go create mode 100644 io.github.lithe-idea.java-navigation/plugin.json create mode 100644 scripts/package.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..bf9db47 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,62 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +jobs: + test: + # Linux runs the full suite and packaging; Windows runs the tests once, + # for its paths, file URIs and the read-only extracted JDK copies. + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + runs-on: ${{ matrix.os }} + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - name: Check formatting + if: runner.os == 'Linux' + run: test -z "$(gofmt -l .)" || { gofmt -l .; exit 1; } + - name: Vet + run: go vet ./... + - name: Test + run: go test -race -count=1 ./... + + package: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - uses: actions/setup-node@v4 + with: + node-version: 22 + - name: Check manifest and build the archives + run: node scripts/package.mjs + - name: Validate against Lithe Store rules + run: | + git clone --depth 1 https://github.com/Lithe-IDEA/Lithe-store "$RUNNER_TEMP/store" + node --input-type=module -e " + import { readPackageManifests, checkManifest } from '$RUNNER_TEMP/store/scripts/validate-index.mjs'; + import { readdirSync, readFileSync } from 'node:fs'; + const manifest = JSON.parse(readFileSync('io.github.lithe-idea.java-navigation/plugin.json', 'utf8')); + const entry = { id: manifest.id }; + let failed = false; + for (const name of readdirSync('dist')) { + const { manifests, unsafe } = readPackageManifests('dist/' + name, entry.id); + const problems = [...unsafe, ...checkManifest(manifests, entry, manifest)]; + for (const problem of problems) { console.error(name + ': ' + problem); failed = true; } + } + process.exit(failed ? 1 : 0); + " diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..f401899 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,66 @@ +name: Release + +# Push a tag v matching plugin.json to publish a GitHub Release. +# Lithe Store's tracker picks up stable releases and opens index pull requests. +# Tags listed in PRERELEASE_TAGS below are published as pre-releases, which the +# tracker skips; they need a manually submitted Store entry. Keep the plain +# v tag. docs/releases/.md, when present, lists the changes. +on: + push: + tags: ["v*"] + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - uses: actions/setup-node@v4 + with: + node-version: 22 + - name: Test + run: go test -count=1 ./... + - name: Build archives + run: | + set -o pipefail + node scripts/package.mjs --expect-tag "$GITHUB_REF_NAME" | tee "$RUNNER_TEMP/checksums.txt" + - name: Publish release + env: + GH_TOKEN: ${{ github.token }} + # v0.0.1 follows plugin contract v0, which only the new desktop + # client installs, and that client ships as desktop-v* pre-releases. + PRERELEASE_TAGS: v0.0.1 + run: | + prerelease=false + for tag in $PRERELEASE_TAGS; do + if [[ "$tag" == "$GITHUB_REF_NAME" ]]; then prerelease=true; fi + done + { + echo "兼容性:按插件契约 v0(schemaVersion 0、pluginAPIVersion 0)编写,需要新版 Lithe 桌面端(desktop-v* 预发布)。旧版 macOS Lithe 无法安装。" + echo + if [[ -s "docs/releases/$GITHUB_REF_NAME.md" ]]; then + cat "docs/releases/$GITHUB_REF_NAME.md" + echo + fi + if [[ "$prerelease" == true ]]; then + echo "安装:预发布版本,Lithe Store 自动追踪会跳过,暂不收录 Store。下载与你的系统和芯片对应的 ZIP,在 设置 → 插件 中点击页面标题栏右侧的 从文件安装…,选中 ZIP 后核对 SHA-256 并确认。" + else + echo "安装:Lithe Store(https://github.com/Lithe-IDEA/Lithe-store)中的 Java Navigation 条目合并后,可在 设置 → 插件 → 插件市场 中搜索 Java Navigation 并安装。" + fi + echo + echo "SHA-256:" + echo '```' + sed 's#^dist/##' "$RUNNER_TEMP/checksums.txt" + echo '```' + } > "$RUNNER_TEMP/notes.md" + release_flags=() + if [[ "$prerelease" == true ]]; then + release_flags+=(--prerelease) + fi + gh release create "$GITHUB_REF_NAME" dist/*.zip --title "Java Navigation $GITHUB_REF_NAME" --notes-file "$RUNNER_TEMP/notes.md" "${release_flags[@]}" diff --git a/README.md b/README.md index 564faf4..1c1a98f 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,96 @@ # Java Navigation for Lithe -Lithe 的轻量 Java 跳转插件(不依赖 JDT LS)。首个版本见 Draft PR。 +为 [Lithe](https://github.com/1lck/Lithe-IDEA) 提供**不依赖 JDT LS 的轻量 Java 跳转**:跳转到定义、查找引用和文档结构。 +插件自带一个用 Go 写的小型语言服务器,不需要 JDK 运行时、不导入 Maven/Gradle 构建,打开项目后第一次跳转约 0.2 秒就能用, +常驻内存几十 MB(JDT LS 通常要 3–5 GB,首次导入几十秒到几分钟)。 +这是一个**社区插件**,按插件契约 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. + +> **预发布**:按插件契约 v0(`schemaVersion: 0`、`pluginAPIVersion: 0`)编写,只能装进新版 Lithe 桌面端 +> (`desktop-v*` 预发布),旧版 macOS Lithe 无法安装。 + +## 功能 + +| 功能 | 说明 | +| --- | --- | +| 跳转到定义 | 类型、方法、构造器、字段、枚举常量、局部变量、参数、类型参数、注解及其属性;按导入、同包、通配导入、嵌套、继承链和调用参数个数解析,支持调用链 `a.b().c` | +| Lombok | `@Data`/`@Getter`/`@Setter` 生成的访问器跳到字段,`@Builder`、`@Slf4j`、构造器注解跳到注解本身(与 JDT LS 一致) | +| 跳进 JDK | 项目里找不到的名字到本机 JDK 的 `src.zip` 里找,`String`、`List`、`hashCode()` 等都能跳,并能在 JDK 源码里继续跳 | +| 查找引用 | 只列出能跳回这个声明的出现,所以列表和在任一行上跳转的结果一致;`package`/`import` 不算引用 | +| 文档结构 | 类型、成员和嵌套类型的大纲(`textDocument/documentSymbol`) | +| 未保存的修改 | 打开的文件按编辑器里的内容索引,未保存的新方法也能从其他文件跳过去 | + +## 怎么工作 + +- 索引只保留**声明**(类型、父类型、成员及其书写的类型、参数个数、Lombok 生成的成员),不保留语法树,也不做类型绑定。 +- 第一次跳转时才建索引:在 Git 仓库里用 `git ls-files` 列出已跟踪和未跟踪的文件(跳过 `.gitignore` 忽略的), + 否则遍历目录并跳过隐藏目录与 `target`、`build`、`out`、`bin`、`node_modules`。超过 2 MiB 的文件跳过。 +- 之后每次查询如果距上次扫描超过 2 秒,就按文件大小和修改时间增量重扫,其他工具改的文件也能反映出来。 +- JDK:按 `JAVA_HOME` → 系统默认 JDK(macOS 的 `/usr/libexec/java_home`,其他系统 `PATH` 上的 `java`)→ 常见安装目录 + (含 IntelliJ 的 `~/.jdks`、SDKMAN!)的顺序找第一个带 `src.zip` 的 JDK。只预先列出 zip 目录,某个类第一次被查到时才解析。 + 跳进 JDK 时把那一个源码文件解压成只读副本,放在 Lithe 为插件分配的状态目录(`${stateDir}`)里,卸载插件时一起删除; + 不写插件包或应用安装目录。 + +解析规则来自 [Viff](https://github.com/1lck/viff)(MIT),见 `internal/codenav/UPSTREAM.md` 和 `THIRD_PARTY_NOTICES.md`。 + +## 实测(Apple M4,macOS,JDK 17) + +在 `src/main/java` 里随机取 1500 个标识符(不含关键字、字符串里的词、`package`/`import` 行),两个种子: + +| 项目 | 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 | + +查找引用 20 次,p50 约 56 ms,最慢 0.13 s。 + +宿主联调:用 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 注解等, +见下面的“限制”。 + +## 限制 + +- **不读依赖库**:Spring、MyBatis、Lombok、Swagger 等依赖里的声明找不到(返回“没有定义”,不会跳错)。 + 计划:从 Maven/Gradle 插件取 classpath,按需读取 `sources.jar`,没有源码时显示只有签名的存根。 +- **没有类型推断**:重载只按参数个数选择;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 声明语言服务器,宿主怎么分配请求还没有定义,暂时不要同时启用。 + +## 开发 + +需要 Go 1.27.3 和 Node.js 22(只用于打包)。 + +```sh +go test ./... # 单元测试和端到端协议测试 +go test -race ./... +node scripts/package.mjs # 交叉编译四个平台并打包到 dist/ +``` + +`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`。 + +语言服务器可以单独运行调试: + +```sh +go run ./cmd/java-navigation --state-dir /tmp/jn-state # 通过 stdin/stdout 说 LSP,日志写 stderr +go run ./cmd/java-navigation --version +``` + +## 发布 + +推送与 `plugin.json` 版本一致的 tag `v`,`release.yml` 会打包并创建 GitHub Release。 +`PRERELEASE_TAGS` 里列出的 tag 发布为预发布,Lithe Store 的自动追踪会跳过,需要手工提交收录。 + +## 许可 + +Apache License 2.0,见 `LICENSE`。`internal/codenav` 等来自 Viff 的代码按 MIT 许可,见 `THIRD_PARTY_NOTICES.md`。 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000..80e36fa --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -0,0 +1,37 @@ +# Third-party notices + +Java Navigation is licensed under the Apache License 2.0 (see `LICENSE`). It includes +code from the following project, under that project's license. + +## Viff + +- Source: https://github.com/1lck/viff +- Used in: `internal/codenav/` (Java declaration index, resolution and usages; copied + unchanged except for files marked as Lithe additions), `internal/jdk/find.go` and + `internal/jdk/sources.go` (JDK discovery and `src.zip` reading, adapted). See + `internal/codenav/UPSTREAM.md`. +- License: MIT + +``` +MIT License + +Copyright (c) 2026 Viff contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +``` diff --git a/cmd/java-navigation/main.go b/cmd/java-navigation/main.go new file mode 100644 index 0000000..d7054fd --- /dev/null +++ b/cmd/java-navigation/main.go @@ -0,0 +1,49 @@ +// Command java-navigation is a lightweight Java language server for Lithe. It +// answers go to definition, find references and the document outline from a +// resident index of declarations, without a JDK runtime, a build import or +// JDT LS. It speaks LSP over standard input and output. +package main + +import ( + "errors" + "flag" + "fmt" + "log" + "os" + + "github.com/Lithe-IDEA/lithe-plugin-java-navigation/internal/jdk" + "github.com/Lithe-IDEA/lithe-plugin-java-navigation/internal/lsp" +) + +// version is set at build time with -ldflags "-X main.version=...". +var version = "dev" + +func main() { + state := flag.String("state-dir", "", "writable directory for extracted JDK sources (the host's ${stateDir})") + printVersion := flag.Bool("version", false, "print the version and exit") + noJDK := flag.Bool("no-jdk", false, "do not navigate into the JDK's sources") + flag.Bool("stdio", true, "speak LSP over standard input and output (the only transport)") + flag.Parse() + if *printVersion { + fmt.Println("java-navigation", version) + return + } + // Standard output carries the protocol; diagnostics go to standard error, + // which the host records in the server's log. + logger := log.New(os.Stderr, "java-navigation: ", log.LstdFlags|log.Lmsgprefix) + opt := lsp.Options{Name: "java-navigation", Version: version, State: *state, Logf: logger.Printf} + if !*noJDK && *state != "" { + opt.FindJDK = jdk.FindJDK + } + if *state == "" { + logger.Printf("no --state-dir: navigation into the JDK is off") + } + err := lsp.New(os.Stdin, os.Stdout, opt).Run() + switch { + case errors.Is(err, lsp.ErrExitWithoutShutdown): + os.Exit(1) + case err != nil: + logger.Printf("%v", err) + os.Exit(1) + } +} diff --git a/docs/releases/v0.0.1.md b/docs/releases/v0.0.1.md new file mode 100644 index 0000000..6541810 --- /dev/null +++ b/docs/releases/v0.0.1.md @@ -0,0 +1,19 @@ +0.0.1 是首个版本:不依赖 JDT LS 的轻量 Java 跳转,按插件契约 v0 编写。 + +功能: +- 跳转到定义:类型、方法、构造器、字段、枚举常量、局部变量、参数、类型参数、注解及其属性;按导入、同包、通配导入、嵌套、继承链和参数个数解析,支持调用链。 +- Lombok:访问器跳到字段,`@Builder`、`@Slf4j` 和构造器注解生成的成员跳到注解。 +- 跳进本机 JDK 的 `src.zip`,并能在 JDK 源码里继续跳;JDK 源码以只读副本打开,放在插件状态目录里。 +- 查找引用:只列出能跳回该声明的出现。 +- 文档结构(大纲)。 +- 打开的文件按编辑器里未保存的内容索引。 + +平台:macOS(Apple Silicon、Intel)、Windows(x64、ARM64),每个平台一个 ZIP。 + +权限:`languageServer.launch`。 + +限制:不读依赖库(Spring、MyBatis、Lombok、Swagger 等的声明找不到);没有 lambda 参数和泛型替换的类型推断;不要与 Java Support(JDT LS)同时启用。 + +兼容性: +- 只能装进新版 Lithe 桌面端(`desktop-v*` 预发布),旧版 macOS Lithe 无法安装。 +- 本版本是预发布,Lithe Store 的自动追踪会跳过它。 diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..35b82eb --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/Lithe-IDEA/lithe-plugin-java-navigation + +go 1.27.3 diff --git a/internal/codenav/UPSTREAM.md b/internal/codenav/UPSTREAM.md new file mode 100644 index 0000000..8061129 --- /dev/null +++ b/internal/codenav/UPSTREAM.md @@ -0,0 +1,20 @@ +# codenav 的上游 + +本目录的 Java 声明索引、解析和使用处查找来自 [Viff](https://github.com/1lck/viff) 的 +`internal/codenav`(MIT 许可,见仓库根目录 `THIRD_PARTY_NOTICES.md`)。 + +- 来源:Viff 工作副本 `viff-main`(2026-11-06),当时这部分代码还没有推到 `1lck/viff` 的任何分支, + 所以这里没有对应的上游提交。上游发布后,把这一行换成对应的提交 SHA。 +- 设计取舍与实测数据:Viff 的 ADR 0040(跳转到声明与使用处)和 ADR 0041(跳转到 JDK)。 + +## 同步规则 + +- 除下面列出的文件,本目录的 `.go` 文件与上游**逐字节一致**,不要在这里直接改;需要改解析规则时, + 先在 Viff 修(先补一条能失败的测试),再整目录拷回来。 +- Lithe 新增的代码放在单独的文件里,文件开头注明“This file is a Lithe addition”: + - `symbols.go`:文档结构(`textDocument/documentSymbol`)。 +- 同步后运行 `go test ./...`。 + +`internal/jdk/find.go` 改编自 Viff 的 `internal/platform/jdk.go`(去掉了 Viff 的进程辅助函数, +只在 macOS 上起的 `java_home` 进程本来就没有控制台窗口);`internal/jdk/sources.go` 改编自 Viff 的 +`internal/app/jdk.go`,加了把单个源码文件解压成只读副本的 `Extract`。 diff --git a/internal/codenav/codenav_test.go b/internal/codenav/codenav_test.go new file mode 100644 index 0000000..0eaea30 --- /dev/null +++ b/internal/codenav/codenav_test.go @@ -0,0 +1,373 @@ +package codenav + +import ( + "strings" + "testing" +) + +// A small multi-package project that exercises each resolution rule. The +// sources compile against Lombok, slf4j and the JDK; those stay outside the +// index, so names from them must resolve to nothing. +var project = map[string]string{ + "src/com/acme/model/BaseEntity.java": `package com.acme.model; + +public abstract class BaseEntity { + protected Long id; + public Long getId() { return id; } +} +`, + "src/com/acme/model/Named.java": `package com.acme.model; + +public interface Named { + String displayName(); +} +`, + "src/com/acme/model/Status.java": `package com.acme.model; + +public enum Status { + ACTIVE, DISABLED; +} +`, + "src/com/acme/model/User.java": `package com.acme.model; + +import java.util.HashMap; +import java.util.Map; +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +public class User extends BaseEntity implements Named { + public static final int MAX = 10; + private String name; + private Address address; + private final Map scores = new HashMap(), extra = null; + + public User(String name) { this.name = name; } + + public String greet(String who) { return "hi " + who; } + + public String greet(String who, int times) { return who; } + + public String displayName() { return name; } + + public T pick(T value) { return value; } + + public static class Address { + public String city; + public Address copy() { return this; } + } +} +`, + "src/com/acme/service/UserRepo.java": `package com.acme.service; + +class UserRepo { + java.util.List findAll() { return null; } + com.acme.model.User first() { return null; } +} +`, + "src/com/acme/util/Strings.java": `package com.acme.util; + +public final class Strings { + public static String trim(String s) { return s; } +} +`, + "src/com/acme/web/Secured.java": `package com.acme.web; + +public @interface Secured { + String action() default ""; +} +`, + "src/com/acme/service/UserService.java": `package com.acme.service; + +import com.acme.model.*; +import com.acme.web.Secured; +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; +import lombok.extern.slf4j.Slf4j; + +import static com.acme.util.Strings.trim; + +@Slf4j +public class UserService { + private final UserRepo repo = new UserRepo(); + + @Secured(action = "read") + @Schema(description = "run") + public String run(User user, Status status) { + String local = user.greet("a"); + log.info(local); + User copy = new User("x"); + copy.setName("y").setAddress(null); + long n = user.getId(); + List users = repo.findAll(); + users.forEach(u -> u.greet("b", 2)); + String twice = user.greet("c", 2); + switch (status) { + case ACTIVE: + break; + default: + break; + } + User.Address a = new User.Address(); + String c = a.copy().city; + String built = User.builder().name("z").build().greet("q"); + String first = repo.first().displayName(); + return trim(local) + helper() + User.MAX + c + built + first + n; + } + + private String helper() { return ""; } +} +`, +} + +func newProject(t *testing.T) *Index { + t.Helper() + ix := NewIndex() + for p, src := range project { + ix.Put(ParseFile(p, []byte(src))) + } + if ix.Len() != len(project) { + t.Fatalf("Len = %d, want %d", ix.Len(), len(project)) + } + return ix +} + +// offsetOf returns the byte offset of the nth (0-based) occurrence of needle +// in src, plus skip bytes. +func offsetOf(t *testing.T, src, needle string, nth, skip int) int { + t.Helper() + off := -1 + for i := 0; i <= nth; i++ { + k := strings.Index(src[off+1:], needle) + if k < 0 { + t.Fatalf("occurrence %d of %q not found", nth, needle) + } + off += 1 + k + } + return off + skip +} + +// declAt is the location of the nth occurrence of needle (its first byte plus +// skip) in project file path. +func declAt(t *testing.T, path, needle string, nth, skip int) Location { + t.Helper() + src := project[path] + off := offsetOf(t, src, needle, nth, skip) + line := strings.Count(src[:off], "\n") + col := off - (strings.LastIndexByte(src[:off], '\n') + 1) + return Location{Path: path, Line: line, Column: col} +} + +func TestResolveRules(t *testing.T) { + const svc = "src/com/acme/service/UserService.java" + const user = "src/com/acme/model/User.java" + cases := []struct { + name string + click string // the occurrence to click in UserService.java + nth int + skip int + want *Location // nil: no declaration in the index + }{ + {"wildcard import type", "User user", 0, 0, ptr(declAt(t, user, "User extends", 0, 0))}, + {"same-package type without import", "UserRepo repo", 0, 0, ptr(declAt(t, "src/com/acme/service/UserRepo.java", "UserRepo", 0, 0))}, + {"parameter", "user.greet", 0, 0, ptr(declAt(t, svc, "user, Status", 0, 0))}, + {"overload by arity: one argument", "greet(\"a\")", 0, 0, ptr(declAt(t, user, "greet(String who)", 0, 0))}, + {"overload by arity: two arguments", "greet(\"c\", 2)", 0, 0, ptr(declAt(t, user, "greet(String who, int", 0, 0))}, + {"lambda parameter", "u.greet", 0, 0, ptr(declAt(t, svc, "u -> ", 0, 0))}, + {"call on an untyped lambda parameter (needs JDK generics)", "greet(\"b\", 2)", 0, 0, nil}, + {"local variable", "trim(local)", 0, 5, ptr(declAt(t, svc, "local = ", 0, 0))}, + {"static import", "trim(local)", 0, 0, ptr(declAt(t, "src/com/acme/util/Strings.java", "trim(", 0, 0))}, + {"private method of the enclosing class", "helper()", 0, 0, ptr(declAt(t, svc, "helper()", 1, 0))}, + {"inherited method", "getId()", 0, 0, ptr(declAt(t, "src/com/acme/model/BaseEntity.java", "getId", 0, 0))}, + {"constructor by arity", "User(\"x\")", 0, 0, ptr(declAt(t, user, "User(String name)", 0, 0))}, + {"Lombok setter", "setName", 0, 0, ptr(declAt(t, user, "name;", 0, 0))}, + {"Lombok chained setter", "setAddress", 0, 0, ptr(declAt(t, user, "address;", 0, 0))}, + {"Lombok logger", "log.info", 0, 0, ptr(declAt(t, svc, "Slf4j\n", 0, 0))}, + {"Lombok builder", "builder()", 0, 0, ptr(declAt(t, user, "Builder\n", 0, 0))}, + {"Lombok builder setter", "name(\"z\")", 0, 0, ptr(declAt(t, user, "Builder\n", 0, 0))}, + {"call after build()", "greet(\"q\")", 0, 0, ptr(declAt(t, user, "greet(String who)", 0, 0))}, + {"enum constant in a case label", "ACTIVE:", 0, 0, ptr(declAt(t, "src/com/acme/model/Status.java", "ACTIVE", 0, 0))}, + {"nested type", "Address()", 0, 0, ptr(declAt(t, user, "Address {", 0, 0))}, + {"field through a call chain", "city;", 0, 0, ptr(declAt(t, user, "city;", 0, 0))}, + {"interface method through a project return type", "displayName()", 0, 0, ptr(declAt(t, user, "displayName()", 0, 0))}, + {"static constant", "MAX +", 0, 0, ptr(declAt(t, user, "MAX", 0, 0))}, + {"field of the enclosing class", "repo.findAll", 0, 0, ptr(declAt(t, svc, "repo = ", 0, 0))}, + {"annotation type", "Secured(", 0, 0, ptr(declAt(t, "src/com/acme/web/Secured.java", "Secured", 0, 0))}, + {"annotation attribute", "action =", 0, 0, ptr(declAt(t, "src/com/acme/web/Secured.java", "action()", 0, 0))}, + {"declaration itself", "run(", 0, 0, ptr(declAt(t, svc, "run(", 0, 0))}, + {"library annotation attribute", "description =", 0, 0, nil}, + {"JDK type", "String local", 0, 0, nil}, + {"JDK method on a JDK type", "forEach", 0, 0, nil}, + {"keyword", "return trim", 0, 0, nil}, + {"string literal", "\"read\"", 0, 1, nil}, + {"comment-free whitespace", " private final", 0, 1, nil}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + ix := newProject(t) + src := project[svc] + off := offsetOf(t, src, c.click, c.nth, c.skip) + got, ok := ix.Resolve(svc, []byte(src), off) + if c.want == nil { + if ok { + t.Fatalf("resolved to %s:%d:%d (%s), want nothing", got.Path, got.Line+1, got.Column+1, got.rule) + } + return + } + if !ok { + t.Fatalf("not resolved, want %s:%d:%d", c.want.Path, c.want.Line+1, c.want.Column+1) + } + if got.Path != c.want.Path || got.Line != c.want.Line || got.Column != c.want.Column { + t.Fatalf("resolved to %s:%d:%d (%s), want %s:%d:%d", got.Path, got.Line+1, got.Column+1, got.rule, + c.want.Path, c.want.Line+1, c.want.Column+1) + } + }) + } +} + +func ptr(l Location) *Location { return &l } + +func TestTypeParameter(t *testing.T) { + const user = "src/com/acme/model/User.java" + ix := newProject(t) + src := project[user] + off := offsetOf(t, src, "T pick", 0, 0) + got, ok := ix.Resolve(user, []byte(src), off) + want := declAt(t, user, "T> T pick", 0, 0) + if !ok || got.Line != want.Line || got.Column != want.Column { + t.Fatalf("type parameter resolved to %+v (%v), want %+v", got, ok, want) + } +} + +// Regressions found while measuring against JDTLS (ADR 0040). +func TestParserRegressions(t *testing.T) { + f := ParseFile("User.java", []byte(project["src/com/acme/model/User.java"])) + if len(f.Types) != 1 { + t.Fatalf("types = %d", len(f.Types)) + } + u := f.Types[0] + // Class annotations after the imports belong to the class, not the header. + if u.Lombok&lombokGetter == 0 || u.Lombok&lombokSetter == 0 || u.BuilderAt.Line < 0 { + t.Errorf("Lombok annotations lost: lombok=%b builder=%v", u.Lombok, u.BuilderAt) + } + // "new HashMap()" must not declare a field named Integer; + // the real second declarator (extra) must still be found. + var fields []string + for _, m := range u.Members { + if m.Kind == kField { + fields = append(fields, m.Name) + } + } + if got := strings.Join(fields, ","); got != "MAX,name,address,scores,extra" { + t.Errorf("fields = %s", got) + } + if len(u.Supers) != 2 || u.Supers[0] != "BaseEntity" || u.Supers[1] != "Named" { + t.Errorf("supers = %v", u.Supers) + } +} + +func TestIncrementalUpdate(t *testing.T) { + const svc = "src/com/acme/service/UserService.java" + const base = "src/com/acme/model/BaseEntity.java" + ix := newProject(t) + src := project[svc] + off := offsetOf(t, src, "getId()", 0, 0) + + moved := "package com.acme.model;\n\npublic abstract class BaseEntity {\n protected Long id;\n\n\n public Long getId() { return id; }\n}\n" + ix.Put(ParseFile(base, []byte(moved))) + got, ok := ix.Resolve(svc, []byte(src), off) + if !ok || got.Path != base || got.Line != 6 { + t.Fatalf("after Put: %+v %v, want %s:7", got, ok, base) + } + ix.Remove(base) + if ix.Has(base) { + t.Fatal("Remove kept the file") + } + if got, ok := ix.Resolve(svc, []byte(src), off); ok { + t.Fatalf("after Remove: resolved to %+v", got) + } + // Resolve indexes the file it is given: an edited service sees its own + // new declarations without a separate Put. + edited := strings.Replace(src, "private String helper()", "\n\nprivate String helper()", 1) + got, ok = ix.Resolve(svc, []byte(edited), offsetOf(t, edited, "helper()", 0, 0)) + want := strings.Count(edited[:offsetOf(t, edited, "helper()", 1, 0)], "\n") + if !ok || got.Line != want { + t.Fatalf("edited file: %+v %v, want line %d", got, ok, want+1) + } +} + +// Columns are byte offsets within the line, whatever the line endings and +// whatever precedes the name on the line. +func TestColumnsWithCRLFAndUnicode(t *testing.T) { + src := "package p;\r\n\r\nclass Grüße {\r\n\tint größe;\r\n\tint get() { return größe; }\r\n}\r\n" + ix := NewIndex() + off := offsetOf(t, src, "größe; }", 0, 0) + got, ok := ix.Resolve("p/Grüße.java", []byte(src), off) + want := offsetOf(t, src, "größe;", 0, 0) - offsetOf(t, src, "\tint größe", 0, 0) + if !ok || got.Line != 3 || got.Column != want { + t.Fatalf("got %+v %v, want line 4 column %d", got, ok, want) + } +} + +// Malformed input (every truncation of a real file) must never panic and +// never resolve outside the source it was given. +func TestTruncatedSourcesDoNotPanic(t *testing.T) { + const svc = "src/com/acme/service/UserService.java" + ix := newProject(t) + full := project[svc] + for n := 0; n <= len(full); n += 3 { + src := []byte(full[:n]) + for off := 0; off < n; off += 11 { + if l, ok := ix.Resolve(svc, src, off); ok && l.Path == svc && l.Line > strings.Count(full[:n], "\n") { + t.Fatalf("prefix %d offset %d: line %d is past the end", n, off, l.Line) + } + } + } +} + +func TestSupports(t *testing.T) { + for p, want := range map[string]bool{"a/B.java": true, "B.JAVA": true, "b.kt": false, "java": false, "x.java.txt": false} { + if Supports(p) != want { + t.Errorf("Supports(%q) = %v", p, !want) + } + } +} + +func BenchmarkResolve(b *testing.B) { + const svc = "src/com/acme/service/UserService.java" + ix := NewIndex() + for p, src := range project { + ix.Put(ParseFile(p, []byte(src))) + } + src := []byte(project[svc]) + off := strings.Index(project[svc], "greet(\"q\")") + b.ReportAllocs() + for b.Loop() { + if _, ok := ix.Resolve(svc, src, off); !ok { + b.Fatal("not resolved") + } + } +} + +// A member type brought in by a static import (Hadoop marks its API with +// "import static ...InterfaceStability.*;" and "@Evolving"). +func TestStaticImportOfMemberType(t *testing.T) { + ix := NewIndex() + ix.Put(ParseFile("a/Stability.java", []byte("package a;\n\npublic class Stability {\n public @interface Evolving {}\n public @interface Stable {}\n}\n"))) + src := "package b;\n\nimport static a.Stability.*;\nimport static a.Stability.Stable;\n\n@Evolving\n@Stable\npublic class Use {}\n" + ix.Put(ParseFile("b/Use.java", []byte(src))) + for _, c := range []struct{ click, want string }{{"Evolving\n", "Evolving {}"}, {"Stable\npublic", "Stable {}"}} { + got, ok := ix.Resolve("b/Use.java", []byte(src), offsetOf(t, src, c.click, 0, 0)) + want := declAtSrc(t, "a/Stability.java", "package a;\n\npublic class Stability {\n public @interface Evolving {}\n public @interface Stable {}\n}\n", c.want) + if !ok || got.Path != want.Path || got.Line != want.Line || got.Column != want.Column { + t.Errorf("%q resolved to %+v ok=%v, want %+v", c.click, got, ok, want) + } + } +} + +func declAtSrc(t *testing.T, path, src, needle string) Location { + t.Helper() + off := offsetOf(t, src, needle, 0, 0) + return Location{Path: path, Line: strings.Count(src[:off], "\n"), Column: off - (strings.LastIndexByte(src[:off], '\n') + 1)} +} diff --git a/internal/codenav/doc.go b/internal/codenav/doc.go new file mode 100644 index 0000000..3a21b98 --- /dev/null +++ b/internal/codenav/doc.go @@ -0,0 +1,22 @@ +// Package codenav answers "go to declaration" and "find usages" for Java +// sources without a language server. +// +// It keeps a resident index of declarations only (types, their supertypes as +// written, and their members with their declared types as written) and, for a +// query, parses just the file under the cursor to scope locals and infer the +// static type of a qualifier. Resolution follows Java's scoping rules for +// what source alone can decide: imports, packages, nesting, inheritance, +// blocks, call arity and the common Lombok annotations. It does not do type +// 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 +// 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. +// +// The package is pure: it reads no files, starts no goroutines and imports +// no other Viff package. The app layer lists and reads files, supplies the +// Library's reader and owns the Index's lifecycle. +package codenav diff --git a/internal/codenav/index.go b/internal/codenav/index.go new file mode 100644 index 0000000..09105fb --- /dev/null +++ b/internal/codenav/index.go @@ -0,0 +1,152 @@ +package codenav + +import ( + "path" + "sort" + "strings" + "sync" +) + +// Supports reports whether path is a source file this package understands. +// Only Java is supported; the check is by extension, case-insensitive so a +// Windows checkout's "Foo.JAVA" is not silently skipped. +func Supports(p string) bool { + return strings.EqualFold(path.Ext(p), ".java") +} + +// Index is the resident declaration table of one working tree: for every +// parsed file its types and their members, and two lookups from a type's +// fully qualified name to its declarations. It holds no source +// text, tokens or syntax trees. +// +// Memory is proportional to the number of declarations. Measured on macOS +// arm64 with Go 1.27.3 (ADR 0040): Nacos, 5,366 files, about 14.5 MiB of +// retained heap; ruoyi-vue-pro, 7,673 files, about 19 MiB. +// +// Names the working tree does not declare are looked up in the Library set +// with SetLibrary, if any. +// +// An Index is safe for concurrent use: Put and Remove take a write lock, +// Resolve takes it only to replace the file it was given, and Usages and the +// other readers share a read lock, so a usage search can run on several +// goroutines while its owner keeps answering. ParseFile, which does the +// expensive part of indexing, takes no lock. +type Index struct { + mu sync.RWMutex + files map[string]*File + byFQN map[string][]*Type + lib *Library +} + +// SetLibrary sets the library consulted for names the working tree does not +// declare; nil removes it. +func (ix *Index) SetLibrary(l *Library) { + ix.mu.Lock() + defer ix.mu.Unlock() + ix.lib = l +} + +// fileOf returns the parsed file loc is in: an indexed one, or a library +// source. The caller holds the read lock. +func (ix *Index) fileOf(loc Location) *File { + if !loc.Library { + return ix.files[loc.Path] + } + if ix.lib == nil { + return nil + } + return ix.lib.file(loc.Path) +} + +// NewIndex returns an empty index. +func NewIndex() *Index { + return &Index{files: map[string]*File{}, byFQN: map[string][]*Type{}} +} + +// Len is the number of indexed files. +func (ix *Index) Len() int { + ix.mu.RLock() + defer ix.mu.RUnlock() + return len(ix.files) +} + +// Has reports whether path is indexed. +func (ix *Index) Has(path string) bool { + ix.mu.RLock() + defer ix.mu.RUnlock() + _, ok := ix.files[path] + return ok +} + +// Paths returns the indexed paths in lexical order. +func (ix *Index) Paths() []string { + ix.mu.RLock() + out := make([]string, 0, len(ix.files)) + for p := range ix.files { + out = append(out, p) + } + ix.mu.RUnlock() + sort.Strings(out) + return out +} + +// Put adds a parsed file, replacing the declarations previously indexed under +// the same path. +func (ix *Index) Put(f *File) { + if f == nil { + return + } + ix.mu.Lock() + defer ix.mu.Unlock() + ix.remove(f.Path) + ix.files[f.Path] = f + var walk func(t *Type) + walk = func(t *Type) { + ix.byFQN[t.FQN] = append(ix.byFQN[t.FQN], t) + for _, c := range t.Nested { + walk(c) + } + } + for _, t := range f.Types { + walk(t) + } +} + +// Remove drops path's declarations; it is a no-op for an unknown path. +func (ix *Index) Remove(path string) { + ix.mu.Lock() + defer ix.mu.Unlock() + ix.remove(path) +} + +func (ix *Index) remove(path string) { + f := ix.files[path] + if f == nil { + return + } + drop := func(m map[string][]*Type, k string, t *Type) { + s := m[k] + for i, x := range s { + if x == t { + s = append(s[:i:i], s[i+1:]...) + break + } + } + if len(s) == 0 { + delete(m, k) + } else { + m[k] = s + } + } + var walk func(t *Type) + walk = func(t *Type) { + drop(ix.byFQN, t.FQN, t) + for _, c := range t.Nested { + walk(c) + } + } + for _, t := range f.Types { + walk(t) + } + delete(ix.files, path) +} diff --git a/internal/codenav/lex.go b/internal/codenav/lex.go new file mode 100644 index 0000000..e7ce03d --- /dev/null +++ b/internal/codenav/lex.go @@ -0,0 +1,145 @@ +package codenav + +import ( + "unicode" + "unicode/utf8" +) + +// A Java tokenizer that only keeps what declaration and reference analysis +// needs: identifiers, punctuation, and opaque literals. Comments are dropped; +// string, char and text-block literals become one token so their contents +// never look like code. + +type tokKind uint8 + +const ( + tIdent tokKind = iota + tPunct + tString + tChar + tNumber +) + +type token struct { + S, E int32 // byte offsets in src + Line int32 // 0-based line of S + Kind tokKind +} + +var javaKeywords = func() map[string]bool { + m := map[string]bool{} + for _, w := range []string{ + "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 +}() + +func lex(src []byte) []token { + toks := make([]token, 0, len(src)/5) + line := int32(0) + i, n := 0, len(src) + for i < n { + c := src[i] + switch { + case c == '\n': + line++ + i++ + case c == ' ' || c == '\t' || c == '\r' || c == '\f': + i++ + case c == '/' && i+1 < n && src[i+1] == '/': + for i < n && src[i] != '\n' { + i++ + } + case c == '/' && i+1 < n && src[i+1] == '*': + i += 2 + for i < n && !(src[i] == '*' && i+1 < n && src[i+1] == '/') { + if src[i] == '\n' { + line++ + } + i++ + } + i += 2 + case c == '"' && i+2 < n && src[i+1] == '"' && src[i+2] == '"': + s, l := i, line + i += 3 + for i < n && !(src[i] == '"' && i+2 < n && src[i+1] == '"' && src[i+2] == '"') { + if src[i] == '\\' { + i++ + } else if src[i] == '\n' { + line++ + } + i++ + } + i += 3 + toks = append(toks, token{int32(s), int32(min(i, n)), l, tString}) + case c == '"' || c == '\'': + s := i + i++ + for i < n && src[i] != c && src[i] != '\n' { + if src[i] == '\\' { + i++ + } + i++ + } + i++ + k := tString + if c == '\'' { + k = tChar + } + toks = append(toks, token{int32(s), int32(min(i, n)), line, k}) + case c >= '0' && c <= '9' || c == '.' && i+1 < n && src[i+1] >= '0' && src[i+1] <= '9': + s := i + for i < n && (isIdentByte(src[i]) || src[i] == '.' || + (src[i] == '+' || src[i] == '-') && (src[i-1] == 'e' || src[i-1] == 'E' || src[i-1] == 'p' || src[i-1] == 'P')) { + i++ + } + toks = append(toks, token{int32(s), int32(i), line, tNumber}) + case isIdentByte(c) || c >= 0x80: + s := i + for i < n { + if src[i] < 0x80 { + if !isIdentByte(src[i]) { + break + } + i++ + continue + } + r, sz := utf8.DecodeRune(src[i:]) + if !unicode.IsLetter(r) && !unicode.IsDigit(r) { + break + } + i += sz + } + if i == s { // a non-letter non-ASCII byte: skip it + _, sz := utf8.DecodeRune(src[i:]) + i += sz + continue + } + toks = append(toks, token{int32(s), int32(i), line, tIdent}) + default: + w := 1 + if i+1 < n { + if c == '-' && src[i+1] == '>' || c == ':' && src[i+1] == ':' { + w = 2 + } + } + if c == '.' && i+2 < n && src[i+1] == '.' && src[i+2] == '.' { + w = 3 + } + toks = append(toks, token{int32(i), int32(i + w), line, tPunct}) + i += w + } + } + return toks +} + +func isIdentByte(c byte) bool { + return c == '_' || c == '$' || c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' +} diff --git a/internal/codenav/library.go b/internal/codenav/library.go new file mode 100644 index 0000000..a35ca65 --- /dev/null +++ b/internal/codenav/library.go @@ -0,0 +1,166 @@ +package codenav + +import ( + "fmt" + "strings" + "sync" +) + +// 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 +// 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. +// +// Library paths are opaque to this package: whatever the reader understands +// (an entry name in the archive). They live in their own namespace, marked by +// File.Lib and Location.Library, so a working-tree file with the same path +// never stands in for a library one. +// +// 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 map[string]string + srcs map[string]bool // the values of paths + read func(path string) ([]byte, error) + + mu sync.RWMutex + files map[string]*File // parsed sources by path; an empty File marks a failed read + byFQN map[string][]*Type // every type of the parsed sources, nested ones too + err error // the latest read failure not reported yet +} + +// 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. +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 + } + } + return l +} + +// 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. +func LibraryFQN(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], ".") { + // A module directory: package directories never contain a dot. + name = name[i+1:] + } + base := name[strings.LastIndexByte(name, '/')+1:] + if base == "module-info" || base == "package-info" || strings.Contains(name, ".") { + return "", false + } + return strings.ReplaceAll(name, "/", "."), true +} + +// Len is the number of sources parsed so far. +func (l *Library) Len() int { + l.mu.RLock() + defer l.mu.RUnlock() + return len(l.files) +} + +// Err returns the latest failure to read a source, once: a lookup that needed +// it answered as if the type did not exist, and the caller decides how to tell +// the user. +func (l *Library) Err() error { + l.mu.Lock() + defer l.mu.Unlock() + err := l.err + l.err = nil + return err +} + +// lookup returns the type named fqn, reading and parsing the source that +// declares it (or declares its outermost type, for a nested one) on first use. +func (l *Library) lookup(fqn string) *Type { + src := "" + for top := fqn; ; { + if p, ok := l.paths[top]; ok { + src = p + break + } + i := strings.LastIndexByte(top, '.') + if i < 0 { + return nil // not a library type: the common case, lock-free + } + top = top[:i] + } + l.mu.RLock() + _, loaded := l.files[src] + ts := l.byFQN[fqn] + l.mu.RUnlock() + if !loaded { + l.load(src) + l.mu.RLock() + ts = l.byFQN[fqn] + l.mu.RUnlock() + } + if len(ts) == 0 { + return nil + } + return ts[0] +} + +// load parses the source at path unless another goroutine did meanwhile. +func (l *Library) load(path string) { + l.mu.Lock() + defer l.mu.Unlock() + if _, ok := l.files[path]; ok { + return + } + f := &File{Path: path, Imports: map[string]string{}, Lib: true} + if src, err := l.read(path); err != nil { + l.err = fmt.Errorf("read library source %s: %w", path, err) + } else { + f = parseJava(src, path, newInterner(), false).f + f.Lib = true + } + l.files[path] = f + var walk func(t *Type) + walk = func(t *Type) { + l.byFQN[t.FQN] = append(l.byFQN[t.FQN], t) + for _, c := range t.Nested { + walk(c) + } + } + for _, t := range f.Types { + walk(t) + } +} + +// file returns the parsed source at path, parsing it if a lookup has not yet. +func (l *Library) file(path string) *File { + l.mu.RLock() + f, ok := l.files[path] + l.mu.RUnlock() + if ok { + return f + } + if !l.srcs[path] { + return nil + } + l.load(path) + l.mu.RLock() + defer l.mu.RUnlock() + return l.files[path] +} diff --git a/internal/codenav/library_test.go b/internal/codenav/library_test.go new file mode 100644 index 0000000..7600bbf --- /dev/null +++ b/internal/codenav/library_test.go @@ -0,0 +1,360 @@ +package codenav + +import ( + "errors" + "fmt" + "sort" + "strings" + "sync" + "testing" +) + +// A miniature JDK laid out like a JDK 9+ src.zip: module directories, then +// packages. Only what the tests navigate through is declared. +var jdk = map[string]string{ + "java.base/java/lang/Object.java": `package java.lang; + +public class Object { + public native int hashCode(); + public boolean equals(Object obj) { return this == obj; } + public String toString() { return null; } +} +`, + "java.base/java/lang/String.java": `package java.lang; + +public final class String implements CharSequence { + private final byte[] value; + + public int length() { + int n = value.length; + return n; + } + + public String trim() { return this; } +} +`, + "java.base/java/lang/CharSequence.java": `package java.lang; + +public interface CharSequence { + int length(); +} +`, + "java.base/java/lang/Enum.java": `package java.lang; + +public abstract class Enum> { + public final String name() { return null; } +} +`, + "java.base/java/util/Collection.java": `package java.util; + +import java.util.stream.Stream; + +public interface Collection { + int size(); + default Stream stream() { return null; } +} +`, + "java.base/java/util/List.java": `package java.util; + +public interface List extends Collection { + E get(int index); +} +`, + "java.base/java/util/Map.java": `package java.util; + +public interface Map { + interface Entry { + K getKey(); + } +} +`, + "java.base/java/util/stream/Stream.java": `package java.util.stream; + +public interface Stream { + long count(); +} +`, + "java.base/module-info.java": "module java.base {}\n", + "java.base/java/lang/package-info.java": "package java.lang;\n", +} + +// jdkPaths are jdk's paths in archive order. +func jdkPaths() []string { + var out []string + for p := range jdk { + out = append(out, p) + } + sort.Strings(out) + return out +} + +// newJDK returns a library over jdk and the paths read so far. +func newJDK() (*Library, func() []string) { + var mu sync.Mutex + var read []string + lib := NewLibrary(jdkPaths(), func(p string) ([]byte, error) { + mu.Lock() + read = append(read, p) + mu.Unlock() + src, ok := jdk[p] + if !ok { + return nil, fmt.Errorf("no entry %s", p) + } + return []byte(src), nil + }) + return lib, func() []string { + mu.Lock() + defer mu.Unlock() + return append([]string(nil), read...) + } +} + +const useJDKPath = "src/com/acme/UseJdk.java" + +const useJDK = `package com.acme; + +import java.util.List; +import java.util.Map; + +public class UseJdk { + enum Color { RED } + + int run(String text, List items, Map.Entry e) { + String t = text.trim(); + Object o = items.stream(); + int h = this.hashCode(); + String n = Color.RED.name(); + e.getKey(); + return t.length() + items.size(); + } +} +` + +func TestLibraryFQN(t *testing.T) { + for _, c := range []struct { + entry, want string + ok bool + }{ + {"java.base/java/lang/String.java", "java.lang.String", true}, + {"java/lang/String.java", "java.lang.String", true}, // JDK 8: no module directory + {"jdk.compiler/com/sun/tools/javac/Main.java", "com.sun.tools.javac.Main", true}, + {"java.base/module-info.java", "", false}, + {"java.base/java/lang/package-info.java", "", false}, + {"java.base/java/lang/String.class", "", false}, + {"META-INF/MANIFEST.MF", "", false}, + {".java", "", false}, + } { + got, ok := LibraryFQN(c.entry) + if got != c.want || ok != c.ok { + t.Errorf("LibraryFQN(%q) = %q, %v; want %q, %v", c.entry, got, ok, c.want, c.ok) + } + } +} + +func TestResolveIntoLibrary(t *testing.T) { + ix := NewIndex() + lib, read := newJDK() + ix.SetLibrary(lib) + ix.Put(ParseFile(useJDKPath, []byte(useJDK))) + at := func(path, needle string) Location { + l := declAtSrc(t, path, jdk[path], needle) + l.Library = true + return l + } + cases := []struct { + click string + nth int + want Location + }{ + // java.lang is imported implicitly. + {"String text", 0, at("java.base/java/lang/String.java", "String implements")}, + {"trim()", 0, at("java.base/java/lang/String.java", "trim()")}, + {"length()", 0, at("java.base/java/lang/String.java", "length()")}, + // An explicit import, a method inherited from a library supertype, + // and a nested library type. + {"List", 0, at("java.base/java/util/List.java", "List")}, + {"stream()", 0, at("java.base/java/util/Collection.java", "stream()")}, + {"size()", 0, at("java.base/java/util/Collection.java", "size()")}, + {"Entry")}, + {"getKey()", 0, at("java.base/java/util/Map.java", "getKey()")}, + // Members every type inherits: Object's, and Enum's for an enum. + {"hashCode()", 0, at("java.base/java/lang/Object.java", "hashCode()")}, + {"name()", 0, at("java.base/java/lang/Enum.java", "name()")}, + {"Object o", 0, at("java.base/java/lang/Object.java", "Object {")}, + } + for _, c := range cases { + got, ok := ix.Resolve(useJDKPath, []byte(useJDK), offsetOf(t, useJDK, c.click, c.nth, 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) + } + } + // Only the sources navigated through were read, each once; the + // archive's other entries never were. + seen := map[string]int{} + for _, p := range read() { + seen[p]++ + if seen[p] > 1 { + t.Errorf("%s read %d times", p, seen[p]) + } + } + if seen["java.base/module-info.java"] > 0 || seen["java.base/java/util/stream/Stream.java"] > 0 { + t.Errorf("read sources nobody navigated to: %v", read()) + } + if lib.Len() != len(seen) { + t.Errorf("library holds %d sources, read %d", lib.Len(), len(seen)) + } +} + +func TestWorkingTreeShadowsLibrary(t *testing.T) { + ix := NewIndex() + lib, _ := newJDK() + ix.SetLibrary(lib) + // The package's own String wins over java.lang's, as in Java. + own := "package com.acme;\n\npublic class String {}\n" + use := "package com.acme;\n\nclass Use { String s; Integer i; }\n" + ix.Put(ParseFile("src/com/acme/String.java", []byte(own))) + ix.Put(ParseFile("src/com/acme/Use.java", []byte(use))) + got, ok := ix.Resolve("src/com/acme/Use.java", []byte(use), offsetOf(t, use, "String s", 0, 0)) + if !ok || got.Library || got.Path != "src/com/acme/String.java" { + t.Errorf("String resolved to %+v ok=%v, want the package's own", got, ok) + } + // A java.lang name the library lacks is still nothing. + if got, ok := ix.Resolve("src/com/acme/Use.java", []byte(use), offsetOf(t, use, "Integer", 0, 0)); ok { + t.Errorf("Integer resolved to %+v", got) + } + // Without a library, java.lang names resolve to nothing as before. + ix.SetLibrary(nil) + src := "package com.acme;\n\nclass Other { Object o; }\n" + ix.Put(ParseFile("src/com/acme/Other.java", []byte(src))) + if got, ok := ix.Resolve("src/com/acme/Other.java", []byte(src), offsetOf(t, src, "Object", 0, 0)); ok { + t.Errorf("Object resolved to %+v without a library", got) + } +} + +func TestResolveInsideLibrary(t *testing.T) { + ix := NewIndex() + lib, _ := newJDK() + ix.SetLibrary(lib) + const path = "java.base/java/lang/String.java" + src := jdk[path] + resolve := func(needle string, nth int) (Location, bool) { + l, ok := ix.ResolveLibrary(path, []byte(src), offsetOf(t, src, needle, nth, 0)) + l.rule = "" + return l, ok + } + // A type of the library's own package, a local, and a declaration. + got, ok := resolve("CharSequence", 0) + want := declAtSrc(t, "java.base/java/lang/CharSequence.java", jdk["java.base/java/lang/CharSequence.java"], "CharSequence {") + want.Library, want.Name = true, "CharSequence" + if !ok || got != want { + t.Errorf("CharSequence resolved to %+v, want %+v", got, want) + } + got, ok = resolve("n;", 0) + want = declAtSrc(t, path, src, "n = ") + want.Library, want.Name, want.Local = true, "n", true + if !ok || got != want { + t.Errorf("local n resolved to %+v, want %+v", got, want) + } + got, ok = resolve("length()", 0) + if !ok || !got.Self || !got.Library || got.Path != path { + t.Errorf("declaration length resolved to %+v", got) + } + // The library source is not added to the index as a working-tree file. + if ix.Has(path) || ix.Len() != 0 { + t.Errorf("a library source entered the index: %v", ix.Paths()) + } +} + +func TestUsagesOfLibraryDeclarations(t *testing.T) { + ix := NewIndex() + lib, _ := newJDK() + ix.SetLibrary(lib) + ix.Put(ParseFile(useJDKPath, []byte(useJDK))) + const path = "java.base/java/lang/String.java" + src := jdk[path] + + // String.length() used in the working tree: the call on a String, not + // Collection.size() and not the array's length inside String itself. + decl, ok := ix.ResolveLibrary(path, []byte(src), offsetOf(t, src, "length()", 0, 0)) + if !ok || !decl.Self { + t.Fatalf("length: %+v ok=%v", decl, ok) + } + got := ix.Usages(useJDKPath, []byte(useJDK), decl, ix.UsageNames(decl, decl.Name)) + want := declAtSrc(t, useJDKPath, useJDK, "length()") + if len(got) != 1 || got[0].Path != useJDKPath || got[0].Library || got[0].Line != want.Line || got[0].Column != want.Column { + t.Errorf("usages of String.length = %+v, want one at %+v", got, want) + } + + // A local declared in a library source is used only there. + decl, ok = ix.ResolveLibrary(path, []byte(src), offsetOf(t, src, "n = ", 0, 0)) + if !ok || !decl.Self || !decl.Local { + t.Fatalf("n: %+v ok=%v", decl, ok) + } + got = ix.LibraryUsages(path, []byte(src), decl, []string{"n"}) + want = declAtSrc(t, path, src, "n;") + if len(got) != 1 || !got[0].Library || got[0].Line != want.Line || got[0].Column != want.Column { + t.Errorf("usages of n = %+v, want one at %+v", got, want) + } + // The same path read as a working-tree file is another file: the + // declaration there is not this one. + if got := ix.Usages(path, []byte(src), decl, []string{"n"}); len(got) != 0 { + t.Errorf("working-tree namesake listed library usages: %+v", got) + } +} + +func TestLibraryReadFailure(t *testing.T) { + ix := NewIndex() + boom := errors.New("archive gone") + calls := 0 + lib := NewLibrary([]string{"java.base/java/lang/Object.java"}, func(string) ([]byte, error) { + calls++ + return nil, boom + }) + ix.SetLibrary(lib) + src := "package a;\n\nclass A { Object o; Object p; }\n" + ix.Put(ParseFile("a/A.java", []byte(src))) + for range 2 { + if got, ok := ix.Resolve("a/A.java", []byte(src), offsetOf(t, src, "Object", 0, 0)); ok { + t.Fatalf("resolved %+v from an unreadable library", got) + } + } + if calls != 1 { + t.Errorf("unreadable source read %d times, want once", calls) + } + if err := lib.Err(); !errors.Is(err, boom) || !strings.Contains(err.Error(), "Object.java") { + t.Errorf("Err() = %v", err) + } + if err := lib.Err(); err != nil { + t.Errorf("Err() reported twice: %v", err) + } +} + +// TestLibraryConcurrentLookups runs under -race in CI: usage searches resolve +// on several goroutines, all loading the same library sources. +func TestLibraryConcurrentLookups(t *testing.T) { + ix := NewIndex() + lib, read := newJDK() + ix.SetLibrary(lib) + ix.Put(ParseFile(useJDKPath, []byte(useJDK))) + var wg sync.WaitGroup + for range 8 { + wg.Add(1) + go func() { + defer wg.Done() + for _, needle := range []string{"trim()", "stream()", "hashCode()", "getKey()"} { + if _, ok := ix.Resolve(useJDKPath, []byte(useJDK), offsetOf(t, useJDK, needle, 0, 0)); !ok { + t.Errorf("%s not resolved", needle) + } + } + }() + } + wg.Wait() + seen := map[string]bool{} + for _, p := range read() { + if seen[p] { + t.Errorf("%s read twice", p) + } + seen[p] = true + } +} diff --git a/internal/codenav/parse.go b/internal/codenav/parse.go new file mode 100644 index 0000000..e28ba04 --- /dev/null +++ b/internal/codenav/parse.go @@ -0,0 +1,719 @@ +package codenav + +import ( + "strings" +) + +// The declaration parser. It reads one Java file and keeps what navigation +// needs: the package, the imports, every type (with its supertypes as written) +// and every member (with its declared type as written). It skips method bodies +// and initializers instead of parsing them; only the file under the cursor is +// parsed again with its body ranges kept (keep=true), so locals can be found. +// It never builds an expression tree and never fails: malformed input yields +// fewer declarations, not an error. + +// declKind is what a declaration declares. +type declKind uint8 + +const ( + kClass declKind = iota + kInterface + kEnum + kRecord + kAnnotation + kMethod + kCtor + kField + kEnumConst +) + +// File is the declarations of one source file, as ParseFile read them. The +// index owns it after Put; callers treat it as opaque. +type File struct { + Path string // repo-relative, slash-separated + Pkg string + Imports map[string]string // simple name -> FQN (single-type imports) + Wild []string // on-demand imports: package or type FQNs + Static []string // static imports: "a.b.C.member" or "a.b.C.*" + Types []*Type + // Lib marks a library source (the JDK): Path is then a library path, + // not a working-tree one (see Library). + Lib bool +} + +// Type is a class, interface, enum, record or annotation type declaration. +// Line and Col locate its name (0-based line, byte column). +type Type struct { + Name, FQN string + Kind declKind + File *File + Line, Col int32 + Outer *Type + Supers []string // as written, generics stripped + Members []*Member + Nested []*Type + Lombok uint8 // lombokGetter | lombokSetter + // Positions of @Builder and @Slf4j-style annotations (-1 if absent): + // Lombok-generated members have no source of their own, so navigation + // lands on the annotation, as JDTLS does. + BuilderAt, LogAt pos + // Lombok constructor annotations: no-args, all-args, required-args (@Data/@Value too). + NoArgsAt, AllArgsAt, ReqArgsAt pos +} + +// pos is a 0-based line and byte column. +type pos struct{ Line, Col int32 } + +var noPos = pos{-1, -1} + +const ( + lombokGetter = 1 << iota + lombokSetter +) + +// Member is a method, constructor, field or enum constant of a Type. +type Member struct { + Name string + Kind declKind + Type string // declared/return type, generics and dims stripped; "" for ctors + Arity int16 + Varargs bool + Owner *Type + Line, Col int32 + Lombok uint8 // field-level @Getter/@Setter + Ret *Type // synthetic members: the return type, already resolved +} + +// parsed is the per-file parse result. The body ranges are only used for the +// file under the cursor and are dropped for the index. +type parsed struct { + f *File + bodies []body + ranges []typeRange + tparams []tparam + toks []token +} + +type body struct { + owner *Type + member *Member // nil for initializer blocks + start, end int // token range: from the parameter list (or '{') to after '}' +} + +type typeRange struct { + t *Type + start, end int // token indexes of the body braces +} + +type parser struct { + src []byte + toks []token + f *File + in *interner + bodies []body + ranges []typeRange + tparams []tparam + keep bool +} + +// interner shares the strings of one parse (type names repeat a lot). It is +// per file on purpose: an index-wide table would keep the names of removed +// files alive for the whole session. +type interner struct { + m map[string]string +} + +func newInterner() *interner { return &interner{m: map[string]string{}} } + +func (in *interner) s(b []byte) string { + if s, ok := in.m[string(b)]; ok { + return s + } + s := string(b) + in.m[s] = s + return s +} + +func (p *parser) txt(i int) string { + if i < 0 || i >= len(p.toks) { + return "" + } + t := p.toks[i] + return string(p.src[t.S:t.E]) +} + +func (p *parser) is(i int, s string) bool { + if i < 0 || i >= len(p.toks) { + return false + } + t := p.toks[i] + return int(t.E-t.S) == len(s) && string(p.src[t.S:t.E]) == s +} + +func (p *parser) ident(i int) bool { + return i >= 0 && i < len(p.toks) && p.toks[i].Kind == tIdent +} + +// col is the byte offset of t within its line. +func (p *parser) col(t token) int32 { + ls := int(t.S) + for ls > 0 && p.src[ls-1] != '\n' { + ls-- + } + return t.S - int32(ls) +} + +// skipBalanced: toks[i] is an opener; returns the index after its closer. +func (p *parser) skipBalanced(i int) int { + open := p.src[p.toks[i].S] + var close byte + switch open { + case '(': + close = ')' + case '[': + close = ']' + case '{': + close = '}' + case '<': + close = '>' + } + depth := 0 + for ; i < len(p.toks); i++ { + t := p.toks[i] + if t.Kind != tPunct { + continue + } + c := p.src[t.S] + if c == open { + depth++ + } else if c == close { + depth-- + if depth == 0 { + return i + 1 + } + } else if open == '<' && (c == ';' || c == '{' || c == '}') { + return i // not a generic list after all + } + } + return i +} + +var modifiers = map[string]bool{ + "public": true, "private": true, "protected": true, "static": true, "final": true, + "abstract": true, "native": true, "synchronized": true, "transient": true, "volatile": true, + "strictfp": true, "default": true, "sealed": true, "non": true, "-": true, +} + +// skipAnnotation: toks[i] is '@' not followed by 'interface'. +func (p *parser) skipAnnotation(i int) (int, string) { + i++ + name := "" + for p.ident(i) { + name = p.txt(i) + i++ + if p.is(i, ".") && p.ident(i+1) { + i++ + continue + } + break + } + if p.is(i, "(") { + i = p.skipBalanced(i) + } + return i, name +} + +type annos struct { + lombok uint8 + builder, logAt pos + noArgs, allArgs, reqArgs pos +} + +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) { + if p.is(i, "@") && !p.is(i+1, "interface") { + if i+1 >= len(p.toks) { // a file that ends in '@' + return i + 1, an + } + var name string + at := p.toks[i+1] + i, name = p.skipAnnotation(i) + lb := &an.lombok + switch name { + case "Builder", "SuperBuilder": + an.builder = pos{at.Line, p.col(at)} + case "Slf4j", "Log4j2", "Log4j", "Log", "CommonsLog", "XSlf4j", "JBossLog", "Flogger": + an.logAt = pos{at.Line, p.col(at)} + case "NoArgsConstructor": + an.noArgs = pos{at.Line, p.col(at)} + case "AllArgsConstructor": + an.allArgs = pos{at.Line, p.col(at)} + case "RequiredArgsConstructor", "Data", "Value": + if an.reqArgs.Line < 0 || name == "RequiredArgsConstructor" { + an.reqArgs = pos{at.Line, p.col(at)} + } + } + switch name { + case "Data", "Getter", "Value": + *lb |= lombokGetter + if name == "Data" { + *lb |= lombokSetter + } + case "Setter": + *lb |= lombokSetter + } + continue + } + if p.ident(i) && modifiers[p.txt(i)] || p.is(i, "-") && p.is(i-1, "non") { + i++ + continue + } + break + } + return i, an +} + +// typeText renders the type in toks[a:b] as a dotted name without generics, +// annotations or array dimensions. +func (p *parser) typeText(a, b int) string { + var sb []byte + for i := a; i < b; i++ { + if p.is(i, "@") { + i, _ = p.skipAnnotation(i) + i-- + continue + } + if p.is(i, "<") { + i = p.skipBalanced(i) - 1 + continue + } + if p.ident(i) { + s := p.txt(i) + if modifiers[s] || s == "final" { + continue + } + if len(sb) > 0 && sb[len(sb)-1] != '.' { + sb = sb[:0] // "Foo Bar": keep the last name (defensive) + } + sb = append(sb, s...) + } else if p.is(i, ".") { + sb = append(sb, '.') + } + } + return p.in.s(sb) +} + +// ParseFile reads the declarations of one Java source file for the index. +// path is the repository-relative, slash-separated path the index reports +// back in a Location. It is safe to call from several goroutines at once. +func ParseFile(path string, src []byte) *File { + return parseJava(src, 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} + p.f = &File{Path: rel, Imports: map[string]string{}} + i := 0 + for i < len(p.toks) { + if p.is(i, "@") && !p.is(i+1, "interface") { + // Only package annotations belong to the header; class + // annotations (@Data, @Getter) are left for the type. + j := i + for p.is(j, "@") && !p.is(j+1, "interface") { + j, _ = p.skipAnnotation(j) + } + if !p.is(j, "package") { + break + } + i = j + continue + } + if p.is(i, "package") || p.is(i, "import") { + isImport := p.is(i, "import") + i++ + static := false + if isImport && p.is(i, "static") { + static = true + i++ + } + var sb []byte + for i < len(p.toks) && !p.is(i, ";") { + sb = append(sb, p.txt(i)...) + i++ + } + i++ + name := string(sb) + switch { + case !isImport: + p.f.Pkg = p.in.s(sb) + case static: + p.f.Static = append(p.f.Static, p.in.s(sb)) + case strings.HasSuffix(name, ".*"): + p.f.Wild = append(p.f.Wild, p.in.s(sb[:len(sb)-2])) + default: + p.f.Imports[p.in.s(sb[strings.LastIndexByte(name, '.')+1:])] = p.in.s(sb) + } + continue + } + break + } + p.parseBody(i, nil, false) + return &parsed{f: p.f, bodies: p.bodies, ranges: p.ranges, tparams: p.tparams, toks: p.toks} +} + +func (p *parser) isTypeKeyword(i int) bool { + switch p.txt(i) { + case "class", "interface", "enum": + return p.ident(i + 1) + case "record": + return p.ident(i+1) && (p.is(i+2, "(") || p.is(i+2, "<")) + } + return p.is(i, "@") && p.is(i+1, "interface") && p.ident(i+2) +} + +// parseBody parses members until the closing '}' of owner (or EOF at top level). +func (p *parser) parseBody(i int, owner *Type, enum bool) int { + n := len(p.toks) + if enum { + i = p.parseEnumConstants(i, owner) + } + for i < n { + if p.is(i, "}") { + return i + 1 + } + if p.is(i, ";") { + i++ + continue + } + var an annos + i, an = p.skipModifiers(i) + lb := an.lombok + if i >= n { + break + } + if p.is(i, "{") { // initializer block + e := p.skipBalanced(i) + if owner != nil { + p.addBody(owner, nil, i, e) + } + i = e + continue + } + if p.isTypeKeyword(i) { + i = p.parseTypeDecl(i, owner, an) + continue + } + tpOpen := -1 + if p.is(i, "<") { + tpOpen = i + i = p.skipBalanced(i) + } + typeStart := i + j, last := i, -1 + for j < n { + switch { + case p.is(j, "<"): + j = p.skipBalanced(j) + case p.is(j, "["): + j = p.skipBalanced(j) + case p.is(j, "@"): + j, _ = p.skipAnnotation(j) + case p.ident(j): + last = j + j++ + case p.is(j, ".") || p.is(j, "?") || p.is(j, "&") || p.is(j, "..."): + j++ + default: + goto stop + } + } + stop: + if j >= n { + break + } + if last < 0 || owner == nil { + if p.is(j, "{") { + i = p.skipBalanced(j) + } else { + i = j + 1 + } + continue + } + switch { + case p.is(j, "("): + m := &Member{Name: p.in.s([]byte(p.txt(last))), Owner: owner, Line: p.toks[last].Line, Col: p.col(p.toks[last])} + if last == typeStart { + m.Kind = kCtor + } else { + m.Kind = kMethod + m.Type = p.typeText(typeStart, last) + } + pe := p.skipBalanced(j) + m.Arity, m.Varargs = p.arity(j, pe) + owner.Members = append(owner.Members, m) + k := pe + sawDefault := false + for k < n { + if p.is(k, "{") && !sawDefault { + e := p.skipBalanced(k) + p.addBody(owner, m, j, e) + k = e + break + } + if p.is(k, ";") { + p.addBody(owner, m, j, k+1) // abstract: parameters still have a scope + k++ + break + } + if p.is(k, "}") { // malformed; let the body loop close + break + } + if p.is(k, "default") { + sawDefault = true + } + if p.is(k, "(") || p.is(k, "{") || p.is(k, "[") { + k = p.skipBalanced(k) + continue + } + k++ + } + p.addTypeParams(tpOpen, tpOpen, k) + i = k + case p.is(j, "=") || p.is(j, ";") || p.is(j, ","): + ft := p.typeText(typeStart, last) + k := j + name := last + for { + p.addField(owner, name, ft, lb) + if p.is(k, "=") { + k = p.skipExpr(k + 1) + } + if p.is(k, ",") && p.ident(k+1) { + name = k + 1 + k += 2 + for p.is(k, "[") { + k = p.skipBalanced(k) + } + continue + } + break + } + if p.is(k, ";") { + k++ + } + i = k + case p.is(j, "{"): // compact record constructor or something odd + e := p.skipBalanced(j) + p.addBody(owner, nil, j, e) + i = e + default: + i = j + 1 + } + } + return i +} + +func (p *parser) addField(owner *Type, at int, typ string, lb uint8) { + owner.Members = append(owner.Members, &Member{ + Name: p.in.s([]byte(p.txt(at))), Kind: kField, Type: typ, Owner: owner, + Line: p.toks[at].Line, Col: p.col(p.toks[at]), Lombok: lb, + }) +} + +func (p *parser) addBody(owner *Type, m *Member, s, e int) { + if p.keep { + p.bodies = append(p.bodies, body{owner, m, s, e}) + } +} + +// skipExpr skips an initializer up to the ',' or ';' that ends it. +func (p *parser) skipExpr(k int) int { + for k < len(p.toks) { + if p.is(k, "(") || p.is(k, "[") || p.is(k, "{") { + k = p.skipBalanced(k) + continue + } + // Type arguments: new HashMap(), Foo.bar(). An uppercase name + // before '<' is a type; a comparison like a < b is left alone. + if p.is(k, "<") && (p.is(k-1, ".") || p.ident(k-1) && isUpper(p.src[p.toks[k-1].S])) { + if e := p.skipBalanced(k); p.is(e-1, ">") { + k = e + continue + } + } + if p.is(k, ",") || p.is(k, ";") || p.is(k, "}") || p.is(k, ")") { + return k + } + k++ + } + return k +} + +// arity counts top-level arguments in toks[open:end) (open is '('). +func (p *parser) arity(open, end int) (int16, bool) { + if end-open <= 2 { + return 0, false + } + n, depth, angle := int16(1), 0, 0 + varargs := false + for k := open + 1; k < end-1; k++ { + switch { + case p.is(k, "(") || p.is(k, "[") || p.is(k, "{"): + depth++ + case p.is(k, ")") || p.is(k, "]") || p.is(k, "}"): + depth-- + case p.is(k, "<") && p.ident(k-1): + angle++ + case p.is(k, ">") && angle > 0: + angle-- + case p.is(k, "...") && depth == 0: + varargs = true + case p.is(k, ",") && depth == 0 && angle == 0: + n++ + } + } + return n, varargs +} + +func (p *parser) parseEnumConstants(i int, owner *Type) int { + for i < len(p.toks) { + if p.is(i, "@") { + i, _ = p.skipAnnotation(i) + continue + } + if p.is(i, ";") { + return i + 1 + } + if p.is(i, "}") { + return i + } + if p.ident(i) { + owner.Members = append(owner.Members, &Member{Name: p.in.s([]byte(p.txt(i))), Kind: kEnumConst, + Type: owner.Name, Owner: owner, Line: p.toks[i].Line, Col: p.col(p.toks[i])}) + i++ + if p.is(i, "(") { + i = p.skipBalanced(i) + } + if p.is(i, "{") { + i = p.skipBalanced(i) + } + continue + } + i++ + } + return i +} + +func (p *parser) parseTypeDecl(i int, outer *Type, an annos) int { + kw := p.txt(i) + if kw == "@" { + i++ + kw = "@interface" + } + 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} + switch kw { + case "class": + t.Kind = kClass + case "interface": + t.Kind = kInterface + case "enum": + t.Kind = kEnum + case "record": + t.Kind = kRecord + default: + t.Kind = kAnnotation + } + if outer != nil { + t.FQN = outer.FQN + "." + t.Name + outer.Nested = append(outer.Nested, t) + } else { + if p.f.Pkg != "" { + t.FQN = p.f.Pkg + "." + t.Name + } else { + t.FQN = t.Name + } + p.f.Types = append(p.f.Types, t) + } + j := nameTok + 1 + mode := "" + for j < len(p.toks) && !p.is(j, "{") { + switch { + case p.is(j, "<"): + j = p.skipBalanced(j) + case p.is(j, "(") && t.Kind == kRecord && mode == "": + e := p.skipBalanced(j) + p.recordComponents(t, j, e) + j = e + case p.is(j, "extends") || p.is(j, "implements") || p.is(j, "permits"): + mode = p.txt(j) + j++ + case p.is(j, "@"): + j, _ = p.skipAnnotation(j) + case p.ident(j) && (mode == "extends" || mode == "implements"): + s := j + for p.ident(j) && p.is(j+1, ".") && p.ident(j+2) { + j += 2 + } + t.Supers = append(t.Supers, p.typeText(s, j+1)) + j++ + default: + j++ + } + } + if j >= len(p.toks) { + return j + } + e := p.parseBody(j+1, t, t.Kind == kEnum) + p.addTypeParams(nameTok+1, nameTok, e) + if p.keep { + p.ranges = append(p.ranges, typeRange{t, j, e}) + } + return e +} + +func (p *parser) recordComponents(t *Type, open, end int) { + s := open + 1 + depth := 0 + for k := open + 1; k < end; k++ { + if p.is(k, "<") || p.is(k, "(") { + depth++ + } else if p.is(k, ">") || p.is(k, ")") && k != end-1 { + depth-- + } + if depth == 0 && (p.is(k, ",") || k == end-1) { + last := k - 1 + if p.ident(last) { + p.addField(t, last, p.typeText(s, last), lombokGetter) + } + s = k + 1 + } + } +} + +func isUpper(c byte) bool { return c >= 'A' && c <= 'Z' } + +type tparam struct { + tok, s, e int // declaring token and scope token range +} + +func (p *parser) addTypeParams(open, s, e int) { + if !p.keep || !p.is(open, "<") { + return + } + end := p.skipBalanced(open) + depth := 0 + for k := open; k < end; k++ { + switch { + case p.is(k, "<"): + depth++ + case p.is(k, ">"): + depth-- + case depth == 1 && p.ident(k) && (p.is(k-1, "<") || p.is(k-1, ",")): + p.tparams = append(p.tparams, tparam{k, s, e}) + } + } +} diff --git a/internal/codenav/resolve.go b/internal/codenav/resolve.go new file mode 100644 index 0000000..8ab53aa --- /dev/null +++ b/internal/codenav/resolve.go @@ -0,0 +1,1117 @@ +package codenav + +import ( + "strings" +) + +// Query-time resolution. Only the file under the cursor is lexed and parsed +// (with its method bodies); everything else comes from the index. The rules, +// in the order Java scoping applies them: the declaration itself, lambda +// parameters, annotations and their attributes, enum constants in a case +// label, qualified access (a.b, a.b(), Type::m) through the static type of +// the qualifier, constructor calls, unqualified calls and names through the +// enclosing types and their supertypes, locals and parameters by block scope, +// type parameters, and finally types through the imports. Lombok-generated +// members (accessors, builders, loggers, constructors) navigate to the field +// or annotation that generates them. +// +// A name the working tree does not declare is looked up in the index's +// Library (the JDK), where java.lang is imported implicitly and every type +// inherits java.lang.Object's members; a name found in neither (a library +// jar) yields no location rather than a guess. + +// Location is a declaration: a repository-relative path (or, with Library +// set, a library path) and the 0-based line and byte column of the declared +// name (or of the Lombok annotation that generates it). +type Location struct { + Path string + Library bool + Line, Column int + // Name is the identifier that was looked up (or, for a usage, the + // identifier found there). For a Lombok accessor it is the accessor's + // name, not the field's. + Name string + // Self reports that the identifier looked up is the declaration itself: + // the place where an editor offers its usages instead. + Self bool + // Local reports a declaration only its own file can refer to: a local + // variable, a parameter or a type parameter. + Local bool + rule string // the rule that produced it, for tests +} + +// hit is the internal form of a Location. +type hit struct { + File string + Lib bool // File is a library path + Line, Col int32 + Why string +} + +type qctx struct { + ix *Index + p *parser // helpers over the current file's tokens + ps *parsed + file *File + depth int + supers map[*Type][]*Type // resolved supertypes, for this query only + builders map[*Type]*Type // owner -> synthetic Lombok builder type + implied map[string]*Type // java.lang supertypes looked up so far, nil if absent +} + +type local struct { + tok int + typeS, typ int // type token range [typeS, typ); typeS<0 means untyped +} + +// Resolve answers "go to declaration" for the identifier at byte offset off +// of src, the current content of the indexed file path. It parses src and +// replaces path's entry with it, so the index never answers from an older +// version of the file the user is looking at. It reports false when the +// offset is not on an identifier or the declaration is not in the index or +// its library. +func (ix *Index) Resolve(path string, src []byte, off int) (Location, bool) { + return ix.resolve(path, false, src, off) +} + +// ResolveLibrary is Resolve for src, the content of the library source at +// path. Library sources do not change, so the index is left alone. +func (ix *Index) ResolveLibrary(path string, src []byte, off int) (Location, bool) { + return ix.resolve(path, true, src, off) +} + +func (ix *Index) resolve(path string, lib bool, src []byte, off int) (Location, bool) { + ps := parseJava(src, path, newInterner(), true) + ps.f.Lib = lib + if !lib { + ix.Put(ps.f) + } + ix.mu.RLock() + defer ix.mu.RUnlock() + p := &parser{src: src, toks: ps.toks, f: ps.f, in: newInterner()} + q := newQuery(ix, p, ps) + ti := -1 + for i, t := range ps.toks { + if int(t.S) <= off && off < int(t.E) { + ti = i + break + } + if int(t.S) > off { + break + } + } + if ti < 0 || !p.ident(ti) || javaKeywords[p.txt(ti)] { + return Location{}, false + } + locs := q.resolveAt(ti) + if len(locs) == 0 { + return Location{}, false + } + l := locs[0] + tok := ps.toks[ti] + return Location{ + Path: l.File, Library: l.Lib, Line: int(l.Line), Column: int(l.Col), Name: p.txt(ti), + Self: l.File == path && l.Lib == lib && l.Line == tok.Line && l.Col == p.col(tok), + Local: l.Why == "decl-local" || l.Why == "local" || l.Why == "type-param", + rule: l.Why, + }, true +} + +func newQuery(ix *Index, p *parser, ps *parsed) *qctx { + return &qctx{ix: ix, p: p, ps: ps, file: ps.f, supers: map[*Type][]*Type{}, builders: map[*Type]*Type{}, + implied: map[string]*Type{}} +} + +func (q *qctx) loc(t *Type, why string) []hit { + if t == nil { + return nil + } + return []hit{{t.File.Path, t.File.Lib, t.Line, t.Col, why}} +} + +func (q *qctx) mloc(m *Member, why string) []hit { + if m == nil { + return nil + } + return []hit{{m.Owner.File.Path, m.Owner.File.Lib, m.Line, m.Col, why}} +} + +// here is a hit in the current file. +func (q *qctx) here(line, col int32, why string) []hit { + return []hit{{q.file.Path, q.file.Lib, line, col, why}} +} + +func (q *qctx) ctxType(ti int) *Type { + var best *Type + bestLen := 1 << 30 + for _, r := range q.ps.ranges { + if r.start <= ti && ti < r.end && r.end-r.start < bestLen { + best, bestLen = r.t, r.end-r.start + } + } + return best +} + +func (q *qctx) bodyAt(ti int) *body { + var best *body + bestLen := 1 << 30 + for i := range q.ps.bodies { + b := &q.ps.bodies[i] + if b.start <= ti && ti < b.end && b.end-b.start < bestLen { + best, bestLen = b, b.end-b.start + } + } + return best +} + +func (q *qctx) resolveAt(ti int) []hit { + p := q.p + name := p.txt(ti) + tok := p.toks[ti] + col := p.col(tok) + ctx := q.ctxType(ti) + + // Declaration site: the identifier names a type or member declared here. + var decl []hit + var walk func(t *Type) + walk = func(t *Type) { + if t.Line == tok.Line && t.Col == col { + decl = q.loc(t, "decl") + } + for _, m := range t.Members { + if m.Line == tok.Line && m.Col == col { + decl = q.mloc(m, "decl") + } + } + for _, c := range t.Nested { + walk(c) + } + } + for _, t := range q.file.Types { + walk(t) + } + if decl != nil { + return decl + } + + isCall := p.is(ti+1, "(") + self := q.here(tok.Line, col, "decl-local") + if q.isLambdaParam(ti) { + return self + } + // A method declared inside a body (anonymous or local class). + if isCall && q.typeStartBack(ti-1) >= 0 { + if e := p.skipBalanced(ti + 1); p.is(e, "{") || p.is(e, "throws") { + return self + } + } + if p.is(ti-1, "@") { + return q.loc(q.resolveSimple(name, q.file, ctx), "annotation") + } + if p.is(ti+1, "=") && !p.is(ti+2, "=") { + if at, isAnn := q.annotationOf(ti); isAnn { + // An attribute of a library annotation (@Schema(description=...)) + // lives outside the project: never fall back to a same-named field. + if at == nil { + return nil + } + return q.mloc(q.findMethod(at, name, 0), "annotation-attr") + } + } + if p.is(ti-1, "case") || p.is(ti-1, ",") && q.inCaseLabel(ti) { + if st := q.switchType(ti, ctx); st != nil { + if m := q.findField(st, name); m != nil { + return q.mloc(m, "case-const") + } + } + } + switch { + case p.is(ti-1, ".") || p.is(ti-1, "::"): + ref := p.is(ti-1, "::") + ot := q.typeOfExpr(ti-2, ctx) + if ot == nil { + // A package-qualified type name: com.foo.Bar + if t := q.qualifiedType(ti); t != nil { + return q.loc(t, "fqn") + } + return nil + } + if isCall && q.afterNew(ti) { + if c := q.pickCtor(ot, q.callArity(ti+1)); c != nil && ot.Name == name { + return q.mloc(c, "ctor") + } + if t := q.findNested(ot, name); t != nil { + if c := q.pickCtor(t, q.callArity(ti+1)); c != nil { + return q.mloc(c, "ctor") + } + return q.loc(t, "new-type") + } + } + if isCall || ref { + ar := -1 + if isCall { + ar = q.callArity(ti + 1) + } + if m := q.findMethod(ot, name, ar); m != nil { + return q.mloc(m, "member-call") + } + return nil + } + if m := q.findField(ot, name); m != nil { + return q.mloc(m, "member-field") + } + if t := q.findNested(ot, name); t != nil { + return q.loc(t, "nested") + } + return nil + case p.is(ti-1, "new"): + t := q.resolveSimple(name, q.file, ctx) + if t == nil { + return nil + } + if isCall || p.is(ti+1, "<") { + open := ti + 1 + if p.is(open, "<") { + open = p.skipBalanced(open) + } + if c := q.pickCtor(t, q.callArity(open)); c != nil { + return q.mloc(c, "ctor") + } + } + return q.loc(t, "new-type") + case isCall: + if m := q.methodInScope(name, q.callArity(ti+1), ctx); m != nil { + return q.mloc(m, "call") + } + return nil + } + if l, ok := q.findLocal(name, ti); ok { + return q.here(p.toks[l.tok].Line, p.col(p.toks[l.tok]), "local") + } + if m := q.fieldInScope(name, ctx); m != nil { + return q.mloc(m, "field") + } + if tp, ok := q.typeParam(name, ti); ok { + return q.here(p.toks[tp].Line, p.col(p.toks[tp]), "type-param") + } + if t := q.resolveSimple(name, q.file, ctx); t != nil { + return q.loc(t, "type") + } + return nil +} + +func (q *qctx) typeParam(name string, ti int) (int, bool) { + best, bl := -1, 1<<30 + for _, tp := range q.ps.tparams { + if tp.s <= ti && ti < tp.e && tp.e-tp.s < bl && q.p.txt(tp.tok) == name { + best, bl = tp.tok, tp.e-tp.s + } + } + return best, best >= 0 +} + +// afterNew reports whether the qualified name ending at ti follows 'new'. +func (q *qctx) afterNew(ti int) bool { + p := q.p + s := ti + for p.is(s-1, ".") && p.ident(s-2) { + s -= 2 + } + return p.is(s-1, "new") +} + +// isLambdaParam reports whether the identifier declares a lambda parameter. +func (q *qctx) isLambdaParam(ti int) bool { + p := q.p + if p.is(ti-1, ".") { + return false + } + if p.is(ti+1, "->") { + return true + } + if !p.is(ti+1, ",") && !p.is(ti+1, ")") { + return false + } + // (a, b) -> / (Foo a, Bar b) ->: walk to the closing paren. + for k := ti + 1; k < len(p.toks); k++ { + switch { + case p.is(k, ")"): + return p.is(k+1, "->") + case p.ident(k) || p.is(k, ",") || p.is(k, ".") || p.is(k, "[") || p.is(k, "]"): + case p.is(k, "<"): + k = p.skipBalanced(k) - 1 + default: + return false + } + } + return false +} + +// enclosingOpen returns the index of the unclosed '(' containing ti. +func (q *qctx) enclosingOpen(ti int, open, close string) int { + p := q.p + d := 0 + for k := ti - 1; k >= 0; k-- { + switch { + case p.is(k, close): + d++ + case p.is(k, open): + if d == 0 { + return k + } + d-- + case open == "(" && (p.is(k, ";") || p.is(k, "{") || p.is(k, "}")): + return -1 + } + } + return -1 +} + +// annotationOf returns the annotation type whose argument list contains ti. +func (q *qctx) annotationOf(ti int) (*Type, bool) { + p := q.p + o := q.enclosingOpen(ti, "(", ")") + if o < 1 || !p.ident(o-1) { + return nil, false + } + s := o - 1 + for p.is(s-1, ".") && p.ident(s-2) { + s -= 2 + } + if !p.is(s-1, "@") { + return nil, false + } + var parts []string + for k := s; k < o; k += 2 { + parts = append(parts, p.txt(k)) + } + return q.resolveText(strings.Join(parts, "."), q.file, q.ctxType(ti)), true +} + +func (q *qctx) inCaseLabel(ti int) bool { + p := q.p + for k := ti - 1; k >= 0 && k > ti-64; k-- { + if p.is(k, "case") { + return true + } + if !p.ident(k) && !p.is(k, ",") && !p.is(k, ".") { + return false + } + } + return false +} + +// switchType infers the selector type of the switch whose body contains ti. +func (q *qctx) switchType(ti int, ctx *Type) *Type { + p := q.p + o := ti + for { + o = q.enclosingOpen(o, "{", "}") + if o < 1 { + return nil + } + if p.is(o-1, ")") { + po := q.matchBack(o - 1) + if po > 0 && p.is(po-1, "switch") { + return q.typeOfExpr(o-2, ctx) + } + } + } +} + +// ---- types and hierarchy ---- + +var primitives = map[string]bool{"boolean": true, "byte": true, "char": true, "short": true, + "int": true, "long": true, "float": true, "double": true, "void": true, "var": true} + +// lookupFQN finds a type by its fully qualified name in the working tree, +// then in the library: a project's own java.lang.String (a JDK checkout) +// wins over the JDK's. +func (q *qctx) lookupFQN(fqn string, from *File) *Type { + ts := q.ix.byFQN[fqn] + if len(ts) == 0 { + if q.ix.lib != nil { + return q.ix.lib.lookup(fqn) + } + return nil + } + if len(ts) == 1 || from == nil { + return ts[0] + } + // Duplicate FQNs across modules: prefer the one sharing the longest path prefix. + best, bl := ts[0], -1 + for _, t := range ts { + l := commonPrefix(t.File.Path, from.Path) + if l > bl { + best, bl = t, l + } + } + return best +} + +func commonPrefix(a, b string) int { + n := 0 + for n < len(a) && n < len(b) && a[n] == b[n] { + n++ + } + return n +} + +func (q *qctx) resolveSimple(name string, f *File, ctx *Type) *Type { + if primitives[name] { + return nil + } + for t := ctx; t != nil; t = t.Outer { + if t.Name == name { + return t + } + if n := q.findNested(t, name); n != nil { + return n + } + } + if fqn, ok := f.Imports[name]; ok { + if t := q.lookupFQN(fqn, f); t != nil { + return t + } + return nil // imported from a library + } + // A member type named by a single static import ("import static + // a.B.Nested;") shadows the package like a single-type import. + if t := q.staticMemberType(name, f, false); t != nil { + return t + } + if f.Pkg != "" { + if t := q.lookupFQN(f.Pkg+"."+name, f); t != nil { + return t + } + } else if t := q.lookupFQN(name, f); t != nil { + return t + } + for _, w := range f.Wild { + if t := q.lookupFQN(w+"."+name, f); t != nil { + return t + } + } + // Every compilation unit imports java.lang on demand. + if t := q.lookupFQN("java.lang."+name, f); t != nil { + return t + } + // Static on-demand imports ("import static a.B.*;") rank with the other + // on-demand imports. + return q.staticMemberType(name, f, true) +} + +// staticMemberType finds a member type called name of a type named by f's +// static imports: the single imports of that name, or (wild) the on-demand +// ones. +func (q *qctx) staticMemberType(name string, f *File, wild bool) *Type { + for _, s := range f.Static { + i := strings.LastIndexByte(s, '.') + if i < 0 || wild != (s[i+1:] == "*") || !wild && s[i+1:] != name { + continue + } + if owner := q.lookupFQN(s[:i], f); owner != nil { + if t := q.findNested(owner, name); t != nil { + return t + } + } + } + return nil +} + +// resolveText resolves a type as written ("Foo", "Outer.Inner", "a.b.Foo"). +func (q *qctx) resolveText(text string, f *File, ctx *Type) *Type { + if text == "" { + return nil + } + parts := strings.Split(text, ".") + t := q.resolveSimple(parts[0], f, ctx) + rest := parts[1:] + if t == nil { + for k := len(parts); k >= 2; k-- { + if t = q.lookupFQN(strings.Join(parts[:k], "."), f); t != nil { + rest = parts[k:] + break + } + } + } + for _, p := range rest { + if t == nil { + return nil + } + t = q.findNested(t, p) + } + return t +} + +func (q *qctx) superTypes(t *Type) []*Type { + if s, ok := q.supers[t]; ok { + return s + } + q.supers[t] = nil // cycle guard + var out []*Type + for _, s := range t.Supers { + if st := q.resolveText(s, t.File, t.Outer); st != nil && st != t { + out = append(out, st) + } + } + q.supers[t] = out + return out +} + +// hierarchy returns t and all its supertypes, breadth first: those written +// in the sources, then the ones Java implies, which only a library provides: +// java.lang.Enum for an enum, java.lang.Record for a record and +// java.lang.Object for every type (an interface has Object's public methods +// too). +func (q *qctx) hierarchy(t *Type) []*Type { + seen := map[*Type]bool{t: true} + out := []*Type{t} + for i := 0; i < len(out) && len(out) < 64; i++ { + for _, s := range q.superTypes(out[i]) { + if !seen[s] { + seen[s] = true + out = append(out, s) + } + } + } + implied := []string{"java.lang.Object"} + switch t.Kind { + case kEnum: + implied = []string{"java.lang.Enum", "java.lang.Object"} + case kRecord: + implied = []string{"java.lang.Record", "java.lang.Object"} + } + for _, fqn := range implied { + if s := q.impliedType(fqn); s != nil && !seen[s] { + seen[s] = true + out = append(out, s) + } + } + return out +} + +// impliedType looks up a java.lang supertype once per query. +func (q *qctx) impliedType(fqn string) *Type { + t, ok := q.implied[fqn] + if !ok { + t = q.lookupFQN(fqn, nil) + q.implied[fqn] = t + } + return t +} + +func (q *qctx) findNested(t *Type, name string) *Type { + for _, h := range q.hierarchy(t) { + for _, n := range h.Nested { + if n.Name == name { + return n + } + } + } + return nil +} + +func arityOK(m *Member, ar int) bool { + if ar < 0 { + return true + } + if m.Varargs { + return ar >= int(m.Arity)-1 + } + return int(m.Arity) == ar +} + +// synth makes a Lombok-generated member that navigates to annotation at. +func synth(owner *Type, name string, at pos, ret *Type) *Member { + return &Member{Name: name, Kind: kMethod, Owner: owner, Line: at.Line, Col: at.Col, Ret: ret} +} + +func (q *qctx) builderOf(t *Type) *Type { + if b, ok := q.builders[t]; ok { + return b + } + b := &Type{Name: t.Name + "Builder", FQN: t.FQN + "." + t.Name + "Builder", Kind: kClass, File: t.File, + Line: t.BuilderAt.Line, Col: t.BuilderAt.Col, Outer: t, BuilderAt: noPos, LogAt: noPos} + q.builders[t] = b + return b +} + +func (q *qctx) findMethod(t *Type, name string, ar int) *Member { + // Members of a synthetic @Builder type: one setter per field, and build(). + if t.Outer != nil && q.builders[t.Outer] == t { + owner := t.Outer + if name == "build" { + return synth(owner, name, owner.BuilderAt, owner) + } + if q.findField(owner, name) != nil { + return synth(owner, name, owner.BuilderAt, t) + } + return nil + } + var first *Member + h := q.hierarchy(t) + for _, ht := range h { + for _, m := range ht.Members { + if m.Kind == kMethod && m.Name == name { + if arityOK(m, ar) { + return m + } + if first == nil { + first = m + } + } + } + } + if first != nil { + return first + } + if name == "builder" { + for _, ht := range h { + if ht.BuilderAt.Line >= 0 { + return synth(ht, name, ht.BuilderAt, q.builderOf(t)) + } + } + } + // Record accessors and Lombok-generated accessors point at the field. + for _, ht := range h { + for _, m := range ht.Members { + if m.Kind != kField { + continue + } + if ht.Kind == kRecord && m.Name == name && ar <= 0 { + return m + } + lb := ht.Lombok | m.Lombok + if lb == 0 { + continue + } + if lb&lombokGetter != 0 && ar <= 0 && (name == "get"+capital(m.Name) || name == "is"+capital(m.Name)) || + lb&lombokSetter != 0 && ar == 1 && name == "set"+capital(m.Name) { + return m + } + } + } + return nil +} + +func capital(s string) string { + if s == "" { + return s + } + return strings.ToUpper(s[:1]) + s[1:] +} + +func (q *qctx) findField(t *Type, name string) *Member { + h := q.hierarchy(t) + for _, ht := range h { + for _, m := range ht.Members { + if (m.Kind == kField || m.Kind == kEnumConst) && m.Name == name { + return m + } + } + } + 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 nil +} + +func (q *qctx) pickCtor(t *Type, ar int) *Member { + var first *Member + for _, m := range t.Members { + if m.Kind == kCtor { + if arityOK(m, ar) { + return m + } + if first == nil { + first = m + } + } + } + if first != nil { + return first + } + // Lombok-generated constructors navigate to their annotation. + order := []pos{t.ReqArgsAt, t.AllArgsAt, t.NoArgsAt} + if ar == 0 { + order = []pos{t.NoArgsAt, t.ReqArgsAt, t.AllArgsAt} + } else if ar > 0 { + order = []pos{t.AllArgsAt, t.ReqArgsAt, t.NoArgsAt} + } + for _, at := range order { + if at.Line >= 0 { + return &Member{Name: t.Name, Kind: kCtor, Owner: t, Line: at.Line, Col: at.Col} + } + } + return nil +} + +func (q *qctx) staticOwners(name string) []*Type { + var out []*Type + for _, s := range q.file.Static { + i := strings.LastIndexByte(s, '.') + if i < 0 || (s[i+1:] != name && s[i+1:] != "*") { + continue + } + if t := q.resolveText(s[:i], q.file, nil); t != nil { + out = append(out, t) + } + } + return out +} + +func (q *qctx) methodInScope(name string, ar int, ctx *Type) *Member { + for t := ctx; t != nil; t = t.Outer { + if m := q.findMethod(t, name, ar); m != nil { + return m + } + } + for _, t := range q.staticOwners(name) { + if m := q.findMethod(t, name, ar); m != nil { + return m + } + } + return nil +} + +func (q *qctx) fieldInScope(name string, ctx *Type) *Member { + for t := ctx; t != nil; t = t.Outer { + if m := q.findField(t, name); m != nil { + return m + } + } + for _, t := range q.staticOwners(name) { + if m := q.findField(t, name); m != nil { + return m + } + } + return nil +} + +func (q *qctx) memberType(m *Member) *Type { + if m.Kind == kEnumConst { + return m.Owner + } + return q.resolveText(m.Type, m.Owner.File, m.Owner) +} + +// ---- expressions ---- + +func (q *qctx) callArity(open int) int { + p := q.p + if !p.is(open, "(") { + return -1 + } + a, _ := p.arity(open, p.skipBalanced(open)) + return int(a) +} + +func (q *qctx) matchBack(close int) int { + p := q.p + c := p.src[p.toks[close].S] + var open byte = '(' + if c == ']' { + open = '[' + } + depth := 0 + for i := close; i >= 0; i-- { + t := p.toks[i] + if t.Kind != tPunct { + continue + } + switch p.src[t.S] { + case c: + depth++ + case open: + depth-- + if depth == 0 { + return i + } + } + } + return -1 +} + +func (q *qctx) qualifiedType(ti int) *Type { + p := q.p + s := ti + for p.is(s-1, ".") && p.ident(s-2) { + s -= 2 + } + var parts []string + for i := s; i <= ti; i += 2 { + parts = append(parts, p.txt(i)) + } + return q.lookupFQN(strings.Join(parts, "."), q.file) +} + +// typeOfExpr infers the static type of the expression ending at token end. +func (q *qctx) typeOfExpr(end int, ctx *Type) *Type { + p := q.p + if end < 0 || q.depth > 16 { + return nil + } + q.depth++ + defer func() { q.depth-- }() + switch { + case p.ident(end): + s := p.txt(end) + if p.is(end-1, ".") { + if s == "this" { // Outer.this + return q.resolveSimple(p.txt(end-2), q.file, ctx) + } + ot := q.typeOfExpr(end-2, ctx) + if ot == nil { + return q.qualifiedType(end) + } + if m := q.findField(ot, s); m != nil { + return q.memberType(m) + } + return q.findNested(ot, s) + } + switch s { + case "this": + return ctx + case "super": + if ctx != nil { + if ss := q.superTypes(ctx); len(ss) > 0 { + return ss[0] + } + } + return nil + } + if l, ok := q.findLocal(s, end); ok { + return q.localType(l, ctx) + } + if m := q.fieldInScope(s, ctx); m != nil { + return q.memberType(m) + } + return q.resolveSimple(s, q.file, ctx) + case p.is(end, ")"): + open := q.matchBack(end) + if open < 0 { + return nil + } + before := open - 1 + if p.is(before, ">") { // new Foo<>(...) + d := 0 + for ; before >= 0; before-- { + if p.is(before, ">") { + d++ + } else if p.is(before, "<") { + d-- + if d == 0 { + break + } + } + } + before-- + } + if p.ident(before) { + nm := p.txt(before) + if q.afterNew(before) { + s := before + for p.is(s-1, ".") && p.ident(s-2) { + s -= 2 + } + var parts []string + for k := s; k <= before; k += 2 { + parts = append(parts, p.txt(k)) + } + return q.resolveText(strings.Join(parts, "."), q.file, ctx) + } + if nm == "this" || nm == "super" || javaKeywords[nm] && nm != "this" { + return nil + } + ar := q.callArity(open) + var m *Member + if p.is(before-1, ".") { + if ot := q.typeOfExpr(before-2, ctx); ot != nil { + m = q.findMethod(ot, nm, ar) + } + } else { + m = q.methodInScope(nm, ar, ctx) + } + if m == nil { + return nil + } + if m.Ret != nil { + return m.Ret + } + if m.Kind == kField { // record/Lombok accessor + // A chained setter compiles only if it returns this + // (@Accessors(chain = true) or lombok.config). + if strings.HasPrefix(nm, "set") && nm != m.Name { + return m.Owner + } + return q.memberType(m) + } + return q.resolveText(m.Type, m.Owner.File, m.Owner) + } + // cast: ((Foo) x) + if p.is(open+1, "(") { + ce := p.skipBalanced(open + 1) + return q.resolveText(p.typeText(open+2, ce-1), q.file, ctx) + } + return q.typeOfExpr(end-1, ctx) + case p.is(end, "]"): + open := q.matchBack(end) + return q.typeOfExpr(open-1, ctx) // element type: dims are not tracked + } + return nil +} + +func (q *qctx) localType(l local, ctx *Type) *Type { + p := q.p + if l.typeS < 0 { + return nil + } + text := p.typeText(l.typeS, l.typ) + if text == "var" { + if p.is(l.tok+1, "=") { + e := p.skipExpr(l.tok + 2) + return q.typeOfExpr(e-1, ctx) + } + return nil + } + return q.resolveText(text, q.file, ctx) +} + +// typeStartBack returns the first token of a type that ends at token e, or -1. +func (q *qctx) typeStartBack(e int) int { + p := q.p + for p.is(e, "]") && p.is(e-1, "[") { + e -= 2 + } + if p.is(e, ">") { + d := 0 + for ; e >= 0; e-- { + switch { + case p.is(e, ">"): + d++ + case p.is(e, "<"): + d-- + case p.ident(e) || p.is(e, ".") || p.is(e, ",") || p.is(e, "?") || p.is(e, "[") || p.is(e, "]"): + default: + return -1 + } + if d == 0 { + break + } + } + e-- + } + if !p.ident(e) { + return -1 + } + s := p.txt(e) + if javaKeywords[s] && !primitives[s] || s == "yield" { + return -1 + } + for p.is(e-1, ".") && p.ident(e-2) { + e -= 2 + } + // What precedes a declaration's type. + b := e - 1 + if b < 0 { + return e + } + if p.ident(b) { + bs := p.txt(b) + if bs == "instanceof" || bs == "var" || modifiers[bs] { + return e + } + // A bare annotation: @RequestParam String name + a := b + for p.is(a-1, ".") && p.ident(a-2) { + a -= 2 + } + if p.is(a-1, "@") { + return e + } + return -1 + } + switch p.txt(b) { + case "(", ",", ";", "{", "}", ")", ":", "->", "<", "&&", "&", "|": + return e + } + return -1 +} + +// findLocal finds the nearest in-scope declaration of name before token at. +func (q *qctx) findLocal(name string, at int) (local, bool) { + p := q.p + b := q.bodyAt(at) + if b == nil { + var r *typeRange + for i := range q.ps.ranges { + x := &q.ps.ranges[i] + if x.start <= at && at < x.end && (r == nil || x.end-x.start < r.end-r.start) { + r = x + } + } + if r == nil { + return local{}, false + } + b = &body{start: r.start, end: r.end} + } + var decls []local + var names []string + var marks []int + add := func(k, ts, te int) { + decls = append(decls, local{k, ts, te}) + names = append(names, p.txt(k)) + } + for k := b.start; k <= at && k < len(p.toks); k++ { + switch { + case p.is(k, "{"): + marks = append(marks, len(decls)) + continue + case p.is(k, "}"): + if n := len(marks); n > 0 { + decls, names = decls[:marks[n-1]], names[:marks[n-1]] + marks = marks[:n-1] + } + continue + case p.is(k, "->"): + if p.ident(k-1) && !p.is(k-2, ".") { + add(k-1, -1, -1) + } else if p.is(k-1, ")") { + o := q.matchBack(k - 1) + for j := o + 1; j < k-1; j++ { + if p.ident(j) && (p.is(j+1, ",") || p.is(j+1, ")")) && q.typeStartBack(j-1) < 0 { + add(j, -1, -1) + } + } + } + continue + } + if !p.ident(k) || javaKeywords[p.txt(k)] { + continue + } + nx := k + 1 + ok := p.is(nx, "=") && !p.is(nx+1, "=") || p.is(nx, ";") || p.is(nx, ",") || p.is(nx, ")") || p.is(nx, ":") + if !ok && (p.is(nx, "&") || p.is(nx, "|") || p.is(nx, "?")) { + if ts := q.typeStartBack(k - 1); ts > 0 && p.is(ts-1, "instanceof") { + ok = true + } + } + if !ok { + continue + } + if ts := q.typeStartBack(k - 1); ts >= 0 { + add(k, ts, k) + } + } + for i := len(decls) - 1; i >= 0; i-- { + if names[i] == name { + return decls[i], true + } + } + return local{}, false +} diff --git a/internal/codenav/symbols.go b/internal/codenav/symbols.go new file mode 100644 index 0000000..e2a4776 --- /dev/null +++ b/internal/codenav/symbols.go @@ -0,0 +1,102 @@ +package codenav + +// This file is a Lithe addition; the rest of the package is copied from Viff +// unchanged (see UPSTREAM.md). Keeping additions in their own files lets the +// package be synced again by copying. + +// SymbolKind is what a Symbol declares. +type SymbolKind uint8 + +const ( + SymbolClass SymbolKind = iota + SymbolInterface + SymbolEnum + SymbolRecord + SymbolAnnotation + SymbolMethod + SymbolConstructor + SymbolField + SymbolEnumConstant +) + +// Symbol is one declaration of a file, for an outline: a type with its +// members and nested types, or a member. Line and Column locate the declared +// name (0-based line, byte column). +type Symbol struct { + Name string + Kind SymbolKind + Detail string // a member's declared type as written; "" otherwise + Line, Column int + Children []Symbol +} + +// Symbols lists the declarations of src, the content of a Java file, in +// source order. Lombok-generated members are not listed: they have no source +// of their own. +func Symbols(src []byte) []Symbol { + f := ParseFile("", src) + out := make([]Symbol, 0, len(f.Types)) + for _, t := range f.Types { + out = append(out, typeSymbol(t)) + } + return out +} + +func typeSymbol(t *Type) Symbol { + s := Symbol{Name: t.Name, Kind: typeKind(t.Kind), Line: int(t.Line), Column: int(t.Col)} + children := make([]Symbol, 0, len(t.Members)+len(t.Nested)) + for _, m := range t.Members { + if m.Ret != nil || m.Line < 0 { + continue // synthetic (Lombok) member + } + children = append(children, Symbol{Name: m.Name, Kind: memberKind(m.Kind), Detail: m.Type, Line: int(m.Line), Column: int(m.Col)}) + } + for _, n := range t.Nested { + children = append(children, typeSymbol(n)) + } + sortSymbols(children) + s.Children = children + return s +} + +// sortSymbols orders symbols by position; members and nested types are +// collected separately but interleave in the source. +func sortSymbols(s []Symbol) { + for i := 1; i < len(s); i++ { + for j := i; j > 0 && before(s[j], s[j-1]); j-- { + s[j], s[j-1] = s[j-1], s[j] + } + } +} + +func before(a, b Symbol) bool { + return a.Line < b.Line || a.Line == b.Line && a.Column < b.Column +} + +func typeKind(k declKind) SymbolKind { + switch k { + case kInterface: + return SymbolInterface + case kEnum: + return SymbolEnum + case kRecord: + return SymbolRecord + case kAnnotation: + return SymbolAnnotation + default: + return SymbolClass + } +} + +func memberKind(k declKind) SymbolKind { + switch k { + case kCtor: + return SymbolConstructor + case kField: + return SymbolField + case kEnumConst: + return SymbolEnumConstant + default: + return SymbolMethod + } +} diff --git a/internal/codenav/usages.go b/internal/codenav/usages.go new file mode 100644 index 0000000..85ebcc8 --- /dev/null +++ b/internal/codenav/usages.go @@ -0,0 +1,189 @@ +package codenav + +import "bytes" + +// Usages are found the way an editor without a language server can afford: +// a file is a candidate only if one of the names occurs in it as a whole +// word; a candidate is parsed like the file under the cursor and every +// occurrence is resolved with the same rules as go to declaration. An +// occurrence is a usage when it navigates to the declaration, so a usage +// list never contradicts what Command+Click on one of its rows would do. +// +// Package and import declarations are not usages: an import only makes a +// name visible, and listing it would turn every single real usage into two. + +// UsageNames returns the identifiers whose occurrences can navigate to decl, +// declared as name: name itself and, for a field with Lombok accessors, the +// getter and setter names that navigate to the field. +func (ix *Index) UsageNames(decl Location, name string) []string { + names := []string{name} + ix.mu.RLock() + defer ix.mu.RUnlock() + m := ix.memberAt(decl) + if m == nil || m.Kind != kField { + return names + } + lb := m.Owner.Lombok | m.Lombok + if lb&lombokGetter != 0 { + names = append(names, "get"+capital(name), "is"+capital(name)) + } + if lb&lombokSetter != 0 { + names = append(names, "set"+capital(name)) + } + return names +} + +// Usages lists the places in src, the current content of path, that navigate +// to decl: occurrences of names (see UsageNames) other than the declaration +// itself and outside package and import declarations, in source order. It +// does not change the index, so it can run on several goroutines at once and +// on content that differs from what is indexed for path. +func (ix *Index) Usages(path string, src []byte, decl Location, names []string) []Location { + return ix.usages(path, false, src, decl, names) +} + +// LibraryUsages is Usages for src, the content of the library source at path: +// the usages of a local variable declared there. +func (ix *Index) LibraryUsages(path string, src []byte, decl Location, names []string) []Location { + return ix.usages(path, true, src, decl, names) +} + +func (ix *Index) usages(path string, lib bool, src []byte, decl Location, names []string) []Location { + if !containsName(src, names) { + return nil + } + ps := parseJava(src, path, newInterner(), true) + ps.f.Lib = lib + p := &parser{src: src, toks: ps.toks, f: ps.f, in: newInterner()} + ix.mu.RLock() + defer ix.mu.RUnlock() + q := newQuery(ix, p, ps) + target := ix.typeAt(decl) + want := make(map[string]bool, len(names)) + for _, n := range names { + want[n] = true + } + var out []Location + for i := 0; i < len(ps.toks); i++ { + if p.is(i, "package") || p.is(i, "import") { + for i < len(ps.toks) && !p.is(i, ";") { + i++ + } + continue + } + if !p.ident(i) || !want[p.txt(i)] { + continue + } + tok := ps.toks[i] + col := p.col(tok) + if path == decl.Path && lib == decl.Library && int(tok.Line) == decl.Line && int(col) == decl.Column { + continue + } + hits := q.resolveAt(i) + if len(hits) == 0 || !refersTo(hits[0], decl, target) { + continue + } + out = append(out, Location{Path: path, Library: lib, Line: int(tok.Line), Column: int(col), Name: p.txt(i), rule: hits[0].Why}) + } + return out +} + +// refersTo reports whether h, what an occurrence navigates to, is decl. For a +// type, a constructor call counts too: "new User()" navigates to the +// constructor (or the Lombok annotation generating it), but it uses the type. +func refersTo(h hit, decl Location, t *Type) bool { + if h.File == decl.Path && h.Lib == decl.Library && int(h.Line) == decl.Line && int(h.Col) == decl.Column { + return true + } + if t == nil || h.Why != "ctor" || h.File != t.File.Path || h.Lib != t.File.Lib { + return false + } + at := pos{h.Line, h.Col} + if at == t.NoArgsAt || at == t.AllArgsAt || at == t.ReqArgsAt { + return true + } + for _, m := range t.Members { + if m.Kind == kCtor && m.Line == h.Line && m.Col == h.Col { + return true + } + } + return false +} + +// typeAt returns the indexed or library type declared at loc, or nil. The +// caller holds the read lock. +func (ix *Index) typeAt(loc Location) *Type { + f := ix.fileOf(loc) + if f == nil { + return nil + } + var found *Type + var walk func(t *Type) + walk = func(t *Type) { + if int(t.Line) == loc.Line && int(t.Col) == loc.Column { + found = t + } + for _, c := range t.Nested { + walk(c) + } + } + for _, t := range f.Types { + walk(t) + } + return found +} + +// memberAt returns the indexed or library member declared at loc, or nil. +// The caller holds the read lock. +func (ix *Index) memberAt(loc Location) *Member { + f := ix.fileOf(loc) + if f == nil { + return nil + } + var found *Member + var walk func(t *Type) + walk = func(t *Type) { + for _, m := range t.Members { + if int(m.Line) == loc.Line && int(m.Col) == loc.Column { + found = m + } + } + for _, c := range t.Nested { + walk(c) + } + } + for _, t := range f.Types { + walk(t) + } + return found +} + +// containsName reports whether one of names occurs in src as a whole word, +// the cheap test that keeps most files from being parsed at all. A match +// inside a comment or string still passes; parsing sorts those out. +func containsName(src []byte, names []string) bool { + for _, n := range names { + if n == "" { + continue + } + b := []byte(n) + for from := 0; ; { + k := bytes.Index(src[from:], b) + if k < 0 { + break + } + s, e := from+k, from+k+len(b) + if (s == 0 || !nameByte(src[s-1])) && (e == len(src) || !nameByte(src[e])) { + return true + } + from = s + 1 + } + } + return false +} + +// nameByte reports a byte that can continue a Java name; every byte of a +// multi-byte rune counts, so non-ASCII names are matched whole. +func nameByte(c byte) bool { + return c == '_' || c == '$' || c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c >= 0x80 +} diff --git a/internal/codenav/usages_test.go b/internal/codenav/usages_test.go new file mode 100644 index 0000000..3c408d7 --- /dev/null +++ b/internal/codenav/usages_test.go @@ -0,0 +1,187 @@ +package codenav + +import ( + "fmt" + "sort" + "strings" + "sync" + "testing" +) + +// usagesOf resolves the nth occurrence of needle (plus skip) in path, which +// must be a declaration, and lists its usages across the project as +// "path:line:col" (1-based), sorted. +func usagesOf(t *testing.T, ix *Index, path, needle string, nth, skip int) []string { + t.Helper() + src := project[path] + decl, ok := ix.Resolve(path, []byte(src), offsetOf(t, src, needle, nth, skip)) + if !ok || !decl.Self { + t.Fatalf("%q in %s is not a declaration: %+v ok=%v", needle, path, decl, ok) + } + names := ix.UsageNames(decl, decl.Name) + var out []string + for _, p := range ix.Paths() { + if decl.Local && p != path { + continue + } + for _, u := range ix.Usages(p, []byte(project[p]), decl, names) { + out = append(out, fmt.Sprintf("%s:%d:%d", u.Path, u.Line+1, u.Column+1)) + } + } + sort.Strings(out) + return out +} + +// at is the "path:line:col" of the nth occurrence of needle plus skip. +func at(t *testing.T, path, needle string, nth, skip int) string { + t.Helper() + l := declAt(t, path, needle, nth, skip) + return fmt.Sprintf("%s:%d:%d", l.Path, l.Line+1, l.Column+1) +} + +func TestUsages(t *testing.T) { + const ( + svc = "src/com/acme/service/UserService.java" + user = "src/com/acme/model/User.java" + strs = "src/com/acme/util/Strings.java" + repo = "src/com/acme/service/UserRepo.java" + status = "src/com/acme/model/Status.java" + secured = "src/com/acme/web/Secured.java" + ) + cases := []struct { + name string + path, needle string + nth, skip int + want []string + }{ + {"method: only the calls that pick this overload", user, "greet(String who)", 0, 0, []string{ + at(t, svc, "greet(\"a\")", 0, 0), + at(t, svc, "greet(\"q\")", 0, 0), + }}, + {"field with Lombok accessors: fields reads and the setter", user, "name;", 0, 0, []string{ + at(t, svc, "setName", 0, 0), + at(t, user, "name = name", 0, 0), + at(t, user, "name; }\n\n public ", 0, 0), + }}, + {"local variable: its own file only", svc, "local = ", 0, 0, []string{ + at(t, svc, "local);", 0, 0), + at(t, svc, "local) +", 0, 0), + }}, + {"static method through a static import, import left out", strs, "trim(", 0, 0, []string{ + at(t, svc, "trim(local)", 0, 0), + }}, + {"nested type, including a constructor call", user, "Address {", 0, 0, []string{ + at(t, svc, "Address a", 0, 0), + at(t, svc, "Address()", 0, 0), + at(t, user, "Address address", 0, 0), + at(t, user, "Address copy", 0, 0), + }}, + {"enum constant", status, "ACTIVE", 0, 0, []string{ + at(t, svc, "ACTIVE:", 0, 0), + }}, + {"annotation attribute", secured, "action()", 0, 0, []string{ + at(t, svc, "action =", 0, 0), + }}, + {"no usages", status, "DISABLED", 0, 0, nil}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got := usagesOf(t, newProject(t), c.path, c.needle, c.nth, c.skip) + want := append([]string(nil), c.want...) + sort.Strings(want) + if strings.Join(got, " ") != strings.Join(want, " ") { + t.Fatalf("usages =\n %s\nwant\n %s", strings.Join(got, "\n "), strings.Join(want, "\n ")) + } + }) + } + + // A type with a declared constructor: "new User(...)" is a usage of the + // type although it navigates to the constructor; the import of a + // wildcard package and the declaration itself are not. + got := usagesOf(t, newProject(t), user, "User extends", 0, 0) + for _, w := range []string{at(t, svc, "User(\"x\")", 0, 0), at(t, svc, "User user", 0, 0), at(t, repo, "User> findAll", 0, 0)} { + if !contains(got, w) { + t.Errorf("usages of User miss %s: %v", w, got) + } + } + if contains(got, at(t, user, "User extends", 0, 0)) { + t.Errorf("usages of User include its declaration: %v", got) + } +} + +func TestSelfAndLocal(t *testing.T) { + ix := newProject(t) + const svc = "src/com/acme/service/UserService.java" + src := []byte(project[svc]) + for _, c := range []struct { + needle string + self, local bool + }{ + {"run(", true, false}, + {"local = ", true, true}, + {"user, Status", true, true}, + {"trim(local)", false, false}, + {"local) +", false, true}, + } { + l, ok := ix.Resolve(svc, src, offsetOf(t, project[svc], c.needle, 0, 0)) + if !ok || l.Self != c.self || l.Local != c.local { + t.Errorf("%q: %+v ok=%v, want self=%v local=%v", c.needle, l, ok, c.self, c.local) + } + } +} + +func TestContainsName(t *testing.T) { + src := []byte("int total = subtotal + total_x; String $a;") + for _, c := range []struct { + names []string + want bool + }{ + {[]string{"total"}, true}, + {[]string{"sub"}, false}, + {[]string{"total_"}, false}, + {[]string{"x", "$a"}, true}, + {[]string{""}, false}, + } { + if got := containsName(src, c.names); got != c.want { + t.Errorf("containsName(%v) = %v, want %v", c.names, got, c.want) + } + } +} + +// Usage searches run on worker goroutines while the owner keeps resolving +// (and so replacing files); run with -race. +func TestUsagesConcurrentWithResolve(t *testing.T) { + ix := newProject(t) + const user = "src/com/acme/model/User.java" + decl, ok := ix.Resolve(user, []byte(project[user]), offsetOf(t, project[user], "greet(String who)", 0, 0)) + if !ok { + t.Fatal("greet not resolved") + } + names := ix.UsageNames(decl, decl.Name) + var wg sync.WaitGroup + for range 4 { + wg.Add(1) + go func() { + defer wg.Done() + for range 50 { + for _, p := range ix.Paths() { + ix.Usages(p, []byte(project[p]), decl, names) + } + } + }() + } + const svc = "src/com/acme/service/UserService.java" + for range 200 { + ix.Resolve(svc, []byte(project[svc]), 10) + } + wg.Wait() +} + +func contains(s []string, v string) bool { + for _, x := range s { + if x == v { + return true + } + } + return false +} diff --git a/internal/jdk/find.go b/internal/jdk/find.go new file mode 100644 index 0000000..faeb514 --- /dev/null +++ b/internal/jdk/find.go @@ -0,0 +1,208 @@ +package jdk + +import ( + "bufio" + "context" + "os" + "os/exec" + "path/filepath" + "runtime" + "sort" + "strconv" + "strings" + "time" +) + +// JDK is an installed Java Development Kit that ships its class library's +// sources, which go to declaration reads (ADR 0041). +type JDK struct { + Home string // the JDK's home directory + Version string // JAVA_VERSION from its release file; "" if unknown + Sources string // the src.zip inside Home +} + +// FindJDK returns the JDK whose sources navigation uses: the one the user +// chose (JAVA_HOME, then the system default: macOS's java_home, or the java +// on PATH elsewhere) when it has sources, otherwise the newest installed JDK +// that has them. It reports false when no JDK with sources is installed; a +// JRE, or a JDK packaged without src.zip (some Linux distributions split it +// into a separate package), does not count. +// +// It reads environment variables, starts at most one short-lived process +// (macOS) and lists a few well-known install directories; it does not search +// the disk. +func FindJDK(ctx context.Context) (JDK, bool) { + home, _ := os.UserHomeDir() + return pickJDK(jdkPreferred(ctx), jdkInstalled(runtime.GOOS, home, os.Getenv)) +} + +// pickJDK returns the first preferred home with sources, else the installed +// home with sources and the highest version (the lexically first path among +// equals, so the choice is stable). +func pickJDK(preferred, installed []string) (JDK, bool) { + for _, h := range preferred { + if j, ok := jdkAt(h); ok { + return j, true + } + } + var found []JDK + seen := map[string]bool{} + for _, h := range installed { + j, ok := jdkAt(h) + if !ok || seen[j.Sources] { + continue + } + seen[j.Sources] = true + found = append(found, j) + } + if len(found) == 0 { + return JDK{}, false + } + sort.SliceStable(found, func(a, b int) bool { + if c := compareJavaVersions(found[a].Version, found[b].Version); c != 0 { + return c > 0 + } + return found[a].Home < found[b].Home + }) + return found[0], true +} + +// jdkAt describes the JDK at home if it has sources: lib/src.zip since JDK 9, +// src.zip in the home directory before. A JDK 8 java executable lives in +// jre/bin, so a home ending in "jre" is retried one level up. +func jdkAt(home string) (JDK, bool) { + if home == "" { + return JDK{}, false + } + if real, err := filepath.EvalSymlinks(home); err == nil { + home = real + } + for _, rel := range []string{filepath.Join("lib", "src.zip"), "src.zip"} { + p := filepath.Join(home, rel) + if fi, err := os.Stat(p); err == nil && fi.Mode().IsRegular() { + return JDK{Home: home, Version: releaseVersion(home), Sources: p}, true + } + } + if filepath.Base(home) == "jre" { + return jdkAt(filepath.Dir(home)) + } + return JDK{}, false +} + +// releaseVersion reads JAVA_VERSION from home's release file. +func releaseVersion(home string) string { + f, err := os.Open(filepath.Join(home, "release")) + if err != nil { + return "" + } + defer f.Close() + sc := bufio.NewScanner(f) + for sc.Scan() { + if v, ok := strings.CutPrefix(strings.TrimSpace(sc.Text()), "JAVA_VERSION="); ok { + return strings.Trim(v, `"`) + } + } + return "" +} + +// compareJavaVersions orders "1.8.0_492" < "11.0.31" < "17.0.18" < "21"; +// an unknown (empty) version sorts first. +func compareJavaVersions(a, b string) int { + ka, kb := javaVersionKey(a), javaVersionKey(b) + for i := 0; i < max(len(ka), len(kb)); i++ { + var x, y int + if i < len(ka) { + x = ka[i] + } + if i < len(kb) { + y = kb[i] + } + if x != y { + if x < y { + return -1 + } + return 1 + } + } + return 0 +} + +// javaVersionKey splits a version into numbers, mapping the "1.x" scheme of +// Java 8 and earlier to x. +func javaVersionKey(v string) []int { + var out []int + for _, f := range strings.FieldsFunc(v, func(r rune) bool { return r < '0' || r > '9' }) { + n, err := strconv.Atoi(f) + if err != nil { + break + } + out = append(out, n) + } + if len(out) > 1 && out[0] == 1 { + out = out[1:] + } + return out +} + +// jdkPreferred lists the homes the user chose, most explicit first. +func jdkPreferred(ctx context.Context) []string { + out := []string{os.Getenv("JAVA_HOME")} + if runtime.GOOS == "darwin" { + // /usr/bin/java is a stub on macOS; java_home names the default JDK. + // A GUI application does not see a shell's JAVA_HOME, so this is the + // usual answer there. + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, "/usr/libexec/java_home") + if b, err := cmd.Output(); err == nil { + out = append(out, strings.TrimSpace(string(b))) + } + return out + } + if java, err := exec.LookPath("java"); err == nil { + if real, err := filepath.EvalSymlinks(java); err == nil { + // /bin/java (or /jre/bin/java for JDK 8). + out = append(out, filepath.Dir(filepath.Dir(real))) + } + } + return out +} + +// jdkInstalled lists the JDK homes in the usual install locations of goos: +// the vendor installers', the package managers' and the ones IntelliJ IDEA +// and SDKMAN! download into. +func jdkInstalled(goos, home string, getenv func(string) string) []string { + var patterns []string + user := func(rel ...string) { + if home != "" { + patterns = append(patterns, filepath.Join(append([]string{home}, rel...)...)) + } + } + switch goos { + case "darwin": + patterns = append(patterns, + "/Library/Java/JavaVirtualMachines/*/Contents/Home", + "/opt/homebrew/opt/openjdk*/libexec/openjdk.jdk/Contents/Home", + "/usr/local/opt/openjdk*/libexec/openjdk.jdk/Contents/Home") + user("Library", "Java", "JavaVirtualMachines", "*", "Contents", "Home") + case "windows": + for _, env := range []string{"ProgramFiles", "ProgramW6432"} { + if pf := getenv(env); pf != "" { + for _, vendor := range []string{"Java", "Eclipse Adoptium", "Microsoft", "Zulu", "Amazon Corretto", "BellSoft"} { + patterns = append(patterns, filepath.Join(pf, vendor, "*")) + } + } + } + user(".jdks", "*") + default: + patterns = append(patterns, "/usr/lib/jvm/*", "/usr/java/*", "/opt/java/*") + user(".jdks", "*") + } + user(".sdkman", "candidates", "java", "*") + var out []string + for _, p := range patterns { + m, _ := filepath.Glob(p) // the patterns are well-formed + out = append(out, m...) + } + return out +} diff --git a/internal/jdk/find_test.go b/internal/jdk/find_test.go new file mode 100644 index 0000000..fc03256 --- /dev/null +++ b/internal/jdk/find_test.go @@ -0,0 +1,138 @@ +package jdk + +import ( + "os" + "path/filepath" + "testing" +) + +// fakeJDK lays out a JDK home under dir: a release file with version (none +// when empty) and src.zip at rel ("" for none). +func fakeJDK(t *testing.T, dir, version, rel string) string { + t.Helper() + if err := os.MkdirAll(filepath.Join(dir, "lib"), 0o755); err != nil { + t.Fatal(err) + } + if version != "" { + if err := os.WriteFile(filepath.Join(dir, "release"), []byte("IMPLEMENTOR=\"x\"\nJAVA_VERSION=\""+version+"\"\n"), 0o644); err != nil { + t.Fatal(err) + } + } + if rel != "" { + if err := os.WriteFile(filepath.Join(dir, rel), []byte("PK"), 0o644); err != nil { + t.Fatal(err) + } + } + real, err := filepath.EvalSymlinks(dir) + if err != nil { + t.Fatal(err) + } + return real +} + +func TestPickJDK(t *testing.T) { + root := t.TempDir() + jdk8 := fakeJDK(t, filepath.Join(root, "zulu-8"), "1.8.0_492", "src.zip") + jdk11 := fakeJDK(t, filepath.Join(root, "temurin-11"), "11.0.31", filepath.Join("lib", "src.zip")) + jdk17 := fakeJDK(t, filepath.Join(root, "openjdk-17"), "17.0.18", filepath.Join("lib", "src.zip")) + jdk21 := fakeJDK(t, filepath.Join(root, "jdk-21"), "21", "") // no sources + jre := fakeJDK(t, filepath.Join(root, "jre-only"), "17", "") + installed := []string{jdk8, jdk11, jdk21, jdk17, jre} + + // Without a preference: the newest JDK that has sources. + j, ok := pickJDK(nil, installed) + if !ok || j.Home != jdk17 || j.Version != "17.0.18" || j.Sources != filepath.Join(jdk17, "lib", "src.zip") { + t.Errorf("newest = %+v ok=%v, want %s", j, ok, jdk17) + } + // A preferred JDK with sources wins over a newer one; JDK 8 keeps + // src.zip in its home. + j, ok = pickJDK([]string{"", jdk8}, installed) + if !ok || j.Home != jdk8 || j.Sources != filepath.Join(jdk8, "src.zip") { + t.Errorf("preferred = %+v ok=%v, want %s", j, ok, jdk8) + } + // A preferred JDK without sources falls through to the others. + j, ok = pickJDK([]string{jdk21, filepath.Join(root, "missing")}, installed) + if !ok || j.Home != jdk17 { + t.Errorf("fallback = %+v ok=%v, want %s", j, ok, jdk17) + } + // JDK 8's java is in jre/bin, so its home is found one level up. + if err := os.MkdirAll(filepath.Join(jdk8, "jre"), 0o755); err != nil { + t.Fatal(err) + } + j, ok = pickJDK([]string{filepath.Join(jdk8, "jre")}, nil) + if !ok || j.Home != jdk8 { + t.Errorf("jre home = %+v ok=%v, want %s", j, ok, jdk8) + } + // A symlinked home (Homebrew's opt/openjdk) is the same JDK once. + link := filepath.Join(root, "link-17") + if err := os.Symlink(jdk17, link); err == nil { + j, ok = pickJDK(nil, []string{link, jdk17}) + if !ok || j.Home != jdk17 { + t.Errorf("symlink = %+v ok=%v", j, ok) + } + } + // Nothing with sources. + if j, ok := pickJDK([]string{jdk21}, []string{jre}); ok { + t.Errorf("found %+v without any sources", j) + } +} + +func TestCompareJavaVersions(t *testing.T) { + for _, c := range []struct { + a, b string + want int + }{ + {"1.8.0_492", "11.0.31", -1}, + {"17.0.18", "11.0.31", 1}, + {"21", "21.0.1", -1}, + {"17.0.2", "17.0.10", -1}, + {"", "1.8", -1}, + {"17", "17.0.0", 0}, + {"22-ea", "21.0.5", 1}, + } { + if got := compareJavaVersions(c.a, c.b); got != c.want { + t.Errorf("compareJavaVersions(%q, %q) = %d, want %d", c.a, c.b, got, c.want) + } + } +} + +func TestJDKInstalledLocations(t *testing.T) { + home := t.TempDir() + sdk := filepath.Join(home, ".sdkman", "candidates", "java", "17.0.8-tem") + idea := filepath.Join(home, ".jdks", "corretto-21") + mac := filepath.Join(home, "Library", "Java", "JavaVirtualMachines", "openjdk-17.jdk", "Contents", "Home") + pf := filepath.Join(home, "Program Files") + win := filepath.Join(pf, "Eclipse Adoptium", "jdk-17.0.8.7-hotspot") + for _, d := range []string{sdk, idea, mac, win} { + if err := os.MkdirAll(d, 0o755); err != nil { + t.Fatal(err) + } + } + env := func(k string) string { + if k == "ProgramFiles" { + return pf + } + return "" + } + for _, c := range []struct { + goos string + want []string + }{ + {"darwin", []string{mac, sdk}}, + {"linux", []string{idea, sdk}}, + {"windows", []string{win, idea, sdk}}, + } { + got := map[string]bool{} + for _, p := range jdkInstalled(c.goos, home, env) { + got[p] = true + } + for _, w := range c.want { + if !got[w] { + t.Errorf("%s: %s not listed in %v", c.goos, w, got) + } + } + if c.goos != "windows" && got[win] || c.goos != "darwin" && got[mac] { + t.Errorf("%s: listed another platform's location: %v", c.goos, got) + } + } +} diff --git a/internal/jdk/sources.go b/internal/jdk/sources.go new file mode 100644 index 0000000..94c261f --- /dev/null +++ b/internal/jdk/sources.go @@ -0,0 +1,228 @@ +package jdk + +import ( + "archive/zip" + "compress/flate" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "hash/crc32" + "io" + "io/fs" + "os" + "path" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/Lithe-IDEA/lithe-plugin-java-navigation/internal/codenav" +) + +// The JDK's class library sources, read from its src.zip (adapted from Viff's +// ADR 0041). Only the archive's directory is read up front; codenav parses a +// class the first time a lookup needs it. The archive is not kept open: each +// read opens it, checks that it is still the file that was listed (a JDK +// update replaces it) and reads one entry. + +// MaxFileSize skips sources too large to have been written by hand. +const MaxFileSize = 2 << 20 + +// ErrChanged reports an archive that changed since it was listed. +var ErrChanged = errors.New("the JDK sources changed on disk") + +// Sources is the listed content of a JDK's src.zip. It never changes after +// Open, so its methods run on any goroutine. +type Sources struct { + version string + archive string + size int64 + mod time.Time + entries map[string]entry // by entry name, .java files only + lib *codenav.Library +} + +// entry locates one stored or deflated file in the archive. +type entry struct { + off, csize, size int64 + deflate bool + crc uint32 +} + +// Open lists the Java sources of j's archive. +func Open(j JDK) (*Sources, error) { + f, err := os.Open(j.Sources) + if err != nil { + return nil, fmt.Errorf("open JDK sources: %w", err) + } + defer f.Close() + fi, err := f.Stat() + if err != nil { + return nil, fmt.Errorf("open JDK sources: %w", err) + } + zr, err := zip.NewReader(f, fi.Size()) + if err != nil { + return nil, fmt.Errorf("read JDK sources: %w", err) + } + s := &Sources{version: j.Version, archive: j.Sources, size: fi.Size(), mod: fi.ModTime(), entries: map[string]entry{}} + names := make([]string, 0, len(zr.File)) + for _, e := range zr.File { + if !codenav.Supports(e.Name) || e.FileInfo().IsDir() || e.Method != zip.Store && e.Method != zip.Deflate { + continue + } + off, err := e.DataOffset() + if err != nil { + return nil, fmt.Errorf("read JDK sources: %w", err) + } + s.entries[e.Name] = entry{off: off, csize: int64(e.CompressedSize64), size: int64(e.UncompressedSize64), + deflate: e.Method == zip.Deflate, crc: e.CRC32} + names = append(names, e.Name) + } + 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) + return s, nil +} + +// Library is the codenav library over these sources. +func (s *Sources) Library() *codenav.Library { return s.lib } + +// Version is the JDK's JAVA_VERSION, "" when unknown. +func (s *Sources) Version() string { return s.version } + +// Archive is the absolute path of the listed src.zip. +func (s *Sources) Archive() string { return s.archive } + +// Has reports whether name is a listed Java source. +func (s *Sources) Has(name string) bool { + _, ok := s.entries[name] + return ok +} + +// Changed reports that the archive is gone or is no longer the listed file. +func (s *Sources) Changed() bool { + fi, err := os.Stat(s.archive) + return err != nil || fi.Size() != s.size || !fi.ModTime().Equal(s.mod) +} + +// Read returns the content of one listed entry. +func (s *Sources) Read(name string) ([]byte, error) { + e, ok := s.entries[name] + if !ok { + return nil, fmt.Errorf("%s: %w", name, fs.ErrNotExist) + } + if e.size > MaxFileSize { + return nil, fmt.Errorf("%s: larger than %d bytes", name, MaxFileSize) + } + f, err := os.Open(s.archive) + if err != nil { + return nil, err + } + defer f.Close() + if fi, err := f.Stat(); err != nil || fi.Size() != s.size || !fi.ModTime().Equal(s.mod) { + return nil, ErrChanged + } + var r io.Reader = io.NewSectionReader(f, e.off, e.csize) + if e.deflate { + fr := flate.NewReader(r) + defer fr.Close() + r = fr + } + b := make([]byte, e.size) + if _, err := io.ReadFull(r, b); err != nil { + return nil, fmt.Errorf("%s: %w", name, err) + } + if crc32.ChecksumIEEE(b) != e.crc { + return nil, fmt.Errorf("%s: %w", name, ErrChanged) + } + return b, nil +} + +// CacheDir is the directory under state that holds the extracted copies of +// these sources. It is named after the archive's identity (path, size and +// modification time), so a replaced JDK never reuses copies of the old one. +func (s *Sources) CacheDir(state string) string { + h := sha256.Sum256([]byte(s.archive + "\x00" + strconv.FormatInt(s.size, 10) + "\x00" + s.mod.UTC().Format(time.RFC3339Nano))) + name := "jdk" + if s.version != "" { + name += "-" + sanitize(s.version) + } + return filepath.Join(state, "jdk-sources", name+"-"+hex.EncodeToString(h[:6])) +} + +// Extract writes a read-only copy of entry name under CacheDir(state), so an +// editor can open it as an ordinary file, and returns its absolute path. An +// existing copy of the same size is reused. The copy is written to a +// temporary file first and renamed into place, so a reader never sees half a +// file. +func (s *Sources) Extract(state, name string) (string, error) { + if !s.Has(name) || !validEntry(name) { + return "", fmt.Errorf("%s: %w", name, fs.ErrNotExist) + } + dst := filepath.Join(s.CacheDir(state), filepath.FromSlash(name)) + if fi, err := os.Stat(dst); err == nil && fi.Mode().IsRegular() && fi.Size() == s.entries[name].size { + return dst, nil + } + b, err := s.Read(name) + if err != nil { + return "", err + } + if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { + return "", fmt.Errorf("extract JDK source: %w", err) + } + tmp, err := os.CreateTemp(filepath.Dir(dst), ".extract-*") + if err != nil { + return "", fmt.Errorf("extract JDK 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 JDK source: %w", err) + } + if err := os.Chmod(tmp.Name(), 0o444); err != nil { + os.Remove(tmp.Name()) + return "", fmt.Errorf("extract JDK 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.Size() == s.entries[name].size { + return dst, nil + } + return "", fmt.Errorf("extract JDK source: %w", err) + } + return dst, nil +} + +// EntryOf maps file, an absolute path, back to the entry it is a copy of, +// when it lies under CacheDir(state). +func (s *Sources) EntryOf(state, file string) (string, bool) { + rel, err := filepath.Rel(s.CacheDir(state), file) + if err != nil || rel == "." || strings.HasPrefix(rel, "..") || filepath.IsAbs(rel) { + return "", false + } + name := filepath.ToSlash(rel) + return name, s.Has(name) +} + +// 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, ":") { + return false + } + return path.Clean(name) == name && !strings.HasPrefix(name, "../") && name != ".." +} + +// sanitize keeps a version usable as part of 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/jdk/sources_test.go b/internal/jdk/sources_test.go new file mode 100644 index 0000000..d1b366d --- /dev/null +++ b/internal/jdk/sources_test.go @@ -0,0 +1,127 @@ +package jdk + +import ( + "archive/zip" + "errors" + "io/fs" + "os" + "path/filepath" + "testing" + "time" +) + +// writeZip writes a JDK 9+ style src.zip with files (entry name -> +// content) and returns its path. +func writeZip(t *testing.T, dir string, files map[string]string) string { + t.Helper() + p := filepath.Join(dir, "src.zip") + f, err := os.Create(p) + 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) + } + if _, err := w.Write([]byte(body)); err != nil { + t.Fatal(err) + } + } + if err := zw.Close(); err != nil { + t.Fatal(err) + } + if err := f.Close(); err != nil { + t.Fatal(err) + } + return p +} + +func TestSourcesExtractAndMapBack(t *testing.T) { + dir := t.TempDir() + archive := writeZip(t, dir, map[string]string{ + "java.base/java/lang/String.java": "package java.lang;\npublic final class String {}\n", + "java.base/module-info.java": "module java.base {}\n", + "README": "not a source", + }) + s, err := Open(JDK{Version: "17.0.18", Sources: archive}) + if err != nil { + t.Fatal(err) + } + if !s.Has("java.base/java/lang/String.java") || s.Has("README") { + t.Fatalf("listed entries are wrong") + } + state := filepath.Join(dir, "state") + file, err := s.Extract(state, "java.base/java/lang/String.java") + if err != nil { + t.Fatal(err) + } + b, err := os.ReadFile(file) + if err != nil || string(b) != "package java.lang;\npublic final class String {}\n" { + t.Fatalf("extracted %q, %v", b, err) + } + if fi, err := os.Stat(file); err != nil || fi.Mode().Perm()&0o222 != 0 { + t.Errorf("extracted copy is writable: %v %v", fi.Mode(), err) + } + // Extracting again reuses the copy. + if again, err := s.Extract(state, "java.base/java/lang/String.java"); err != nil || again != file { + t.Errorf("second extract = %q, %v", again, err) + } + if name, ok := s.EntryOf(state, file); !ok || name != "java.base/java/lang/String.java" { + t.Errorf("EntryOf = %q, %v", name, ok) + } + if _, ok := s.EntryOf(state, filepath.Join(dir, "Other.java")); ok { + t.Error("a file outside the cache maps to an entry") + } + if _, err := s.Extract(state, "README"); !errors.Is(err, fs.ErrNotExist) { + t.Errorf("extracting a non-source = %v", err) + } +} + +func TestSourcesNoticeAReplacedArchive(t *testing.T) { + dir := t.TempDir() + archive := writeZip(t, dir, map[string]string{"java/lang/Object.java": "package java.lang;\npublic class Object {}\n"}) + s, err := Open(JDK{Sources: archive}) + if err != nil { + t.Fatal(err) + } + before := s.CacheDir(dir) + if s.Changed() { + t.Fatal("a fresh listing reports a change") + } + writeZip(t, dir, map[string]string{"java/lang/Object.java": "package java.lang;\npublic class Object { int changed; }\n"}) + // Pin a different modification time, whatever the file system's resolution. + if err := os.Chtimes(archive, time.Now(), time.Now().Add(-time.Hour)); err != nil { + t.Fatal(err) + } + if !s.Changed() { + t.Fatal("a replaced archive is not reported") + } + if _, err := s.Read("java/lang/Object.java"); !errors.Is(err, ErrChanged) { + t.Errorf("read of a replaced archive = %v, want ErrChanged", err) + } + s2, err := Open(JDK{Sources: archive}) + if err != nil { + t.Fatal(err) + } + if s2.CacheDir(dir) == before { + t.Error("a replaced archive reuses the old cache directory") + } +} + +func TestValidEntry(t *testing.T) { + for name, want := range map[string]bool{ + "java.base/java/lang/String.java": true, + "../escape.java": false, + "a/../../escape.java": false, + "/abs.java": false, + `a\b.java`: false, + "C:/x.java": false, + "a//b.java": false, + } { + if got := validEntry(name); got != want { + t.Errorf("validEntry(%q) = %v, want %v", name, got, want) + } + } +} diff --git a/internal/lsp/documents.go b/internal/lsp/documents.go new file mode 100644 index 0000000..0e33244 --- /dev/null +++ b/internal/lsp/documents.go @@ -0,0 +1,40 @@ +package lsp + +import "fmt" + +// document is the editor's content of an open file. +type document struct { + uri string + path string + version int + text []byte +} + +// contentChange is one entry of didChange's contentChanges: a replacement of +// range, or of the whole text when range is absent. +type contentChange struct { + Range *Range `json:"range,omitempty"` + Text string `json:"text"` +} + +// apply replaces the text in order of changes; positions of each change +// refer to the text after the previous one. +func (d *document) apply(changes []contentChange, enc encoding) error { + for _, c := range changes { + if c.Range == nil { + d.text = []byte(c.Text) + continue + } + start := offsetOf(d.text, c.Range.Start, enc) + end := offsetOf(d.text, c.Range.End, enc) + if end < start { + return fmt.Errorf("change range ends before it starts") + } + next := make([]byte, 0, len(d.text)-(end-start)+len(c.Text)) + next = append(next, d.text[:start]...) + next = append(next, c.Text...) + next = append(next, d.text[end:]...) + d.text = next + } + return nil +} diff --git a/internal/lsp/jsonrpc.go b/internal/lsp/jsonrpc.go new file mode 100644 index 0000000..a5697e5 --- /dev/null +++ b/internal/lsp/jsonrpc.go @@ -0,0 +1,138 @@ +package lsp + +import ( + "bufio" + "encoding/json" + "errors" + "fmt" + "io" + "net/textproto" + "strconv" + "strings" + "sync" +) + +// maxMessageSize bounds one incoming message; a document is at most a few +// MiB of text, sent once on open. +const maxMessageSize = 64 << 20 + +// JSON-RPC error codes the server answers with. +const ( + codeParseError = -32700 + codeInvalidRequest = -32600 + codeMethodNotFound = -32601 + codeInvalidParams = -32602 + codeInternalError = -32603 + codeServerNotInit = -32002 + codeRequestCancelled = -32800 +) + +// message is an incoming request, notification or response. +type message struct { + JSONRPC string `json:"jsonrpc"` + ID json.RawMessage `json:"id,omitempty"` + Method string `json:"method,omitempty"` + Params json.RawMessage `json:"params,omitempty"` +} + +// isRequest reports a request, which needs an answer; a notification has no +// id, and a response (to a request this server sent) has no method. +func (m *message) isRequest() bool { return len(m.ID) > 0 && m.Method != "" } + +type responseError struct { + Code int `json:"code"` + Message string `json:"message"` +} + +type response struct { + JSONRPC string `json:"jsonrpc"` + ID json.RawMessage `json:"id"` + Result any `json:"result"` + Error *responseError `json:"error,omitempty"` +} + +// errorResponse omits result, which a response carrying an error must not +// have. +type errorResponse struct { + JSONRPC string `json:"jsonrpc"` + ID json.RawMessage `json:"id"` + Error *responseError `json:"error"` +} + +type notification struct { + JSONRPC string `json:"jsonrpc"` + Method string `json:"method"` + Params any `json:"params"` +} + +// conn reads and writes base-protocol messages: a Content-Length header, +// a blank line and a JSON body. +type conn struct { + r *bufio.Reader + mu sync.Mutex + w io.Writer +} + +func newConn(r io.Reader, w io.Writer) *conn { + return &conn{r: bufio.NewReaderSize(r, 64<<10), w: w} +} + +// read returns the next message; io.EOF when the client closed the stream. +func (c *conn) read() (*message, error) { + tp := textproto.NewReader(c.r) + header, err := tp.ReadMIMEHeader() + if err != nil { + if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) { + return nil, io.EOF + } + return nil, fmt.Errorf("read header: %w", err) + } + n, err := strconv.Atoi(strings.TrimSpace(header.Get("Content-Length"))) + if err != nil || n < 0 { + return nil, fmt.Errorf("invalid Content-Length %q", header.Get("Content-Length")) + } + if n > maxMessageSize { + return nil, fmt.Errorf("message of %d bytes exceeds %d", n, maxMessageSize) + } + body := make([]byte, n) + if _, err := io.ReadFull(c.r, body); err != nil { + return nil, fmt.Errorf("read body: %w", err) + } + var m message + if err := json.Unmarshal(body, &m); err != nil { + return &message{}, errParse + } + return &m, nil +} + +// errParse marks a body that is not JSON; the stream itself is still in step. +var errParse = errors.New("parse error") + +func (c *conn) write(v any) error { + body, err := json.Marshal(v) + if err != nil { + return err + } + c.mu.Lock() + defer c.mu.Unlock() + if _, err := fmt.Fprintf(c.w, "Content-Length: %d\r\n\r\n", len(body)); err != nil { + return err + } + _, err = c.w.Write(body) + return err +} + +func (c *conn) reply(id json.RawMessage, result any) error { + return c.write(response{JSONRPC: "2.0", ID: id, Result: result}) +} + +func (c *conn) replyError(id json.RawMessage, code int, msg string) error { + if len(id) == 0 { + id = json.RawMessage("null") + } + return c.write(errorResponse{JSONRPC: "2.0", ID: id, Error: &responseError{Code: code, Message: msg}}) +} + +func (c *conn) notify(method string, params any) error { + return c.write(notification{JSONRPC: "2.0", Method: method, Params: params}) +} diff --git a/internal/lsp/position.go b/internal/lsp/position.go new file mode 100644 index 0000000..f5cf108 --- /dev/null +++ b/internal/lsp/position.go @@ -0,0 +1,154 @@ +package lsp + +import ( + "bytes" + "unicode/utf16" + "unicode/utf8" +) + +// encoding is how a position's character counts within a line: UTF-16 code +// units (the protocol's default) or bytes, when the client offers UTF-8. +type encoding int + +const ( + utf16Units encoding = iota + utf8Bytes +) + +func (e encoding) String() string { + if e == utf8Bytes { + return "utf-8" + } + return "utf-16" +} + +// Position is a zero-based line and character. +type Position struct { + Line int `json:"line"` + Character int `json:"character"` +} + +// Range is a half-open span between two positions. +type Range struct { + Start Position `json:"start"` + End Position `json:"end"` +} + +// lineBounds returns the byte span of the 0-based line of text, without its +// line terminator; ok is false past the last line. +func lineBounds(text []byte, line int) (start, end int, ok bool) { + if line < 0 { + return 0, 0, false + } + start = 0 + for i := 0; i < line; i++ { + j := bytes.IndexByte(text[start:], '\n') + if j < 0 { + return 0, 0, false + } + start += j + 1 + } + end = start + if j := bytes.IndexByte(text[start:], '\n'); j >= 0 { + end = start + j + } else { + end = len(text) + } + if end > start && text[end-1] == '\r' { + end-- + } + return start, end, true +} + +// offsetOf converts p to a byte offset of text. A character past the end of +// its line clamps to the line end, and a line past the end of text to the end +// of text, as the protocol asks. +func offsetOf(text []byte, p Position, enc encoding) int { + start, end, ok := lineBounds(text, p.Line) + if !ok { + return len(text) + } + return start + byteColumn(text[start:end], p.Character, enc) +} + +// byteColumn converts a character count within line to a byte count. +func byteColumn(line []byte, char int, enc encoding) int { + if char <= 0 { + return 0 + } + if enc == utf8Bytes { + return min(char, len(line)) + } + units, i := 0, 0 + for i < len(line) && units < char { + r, size := utf8.DecodeRune(line[i:]) + n := 1 + if r >= 0x10000 && r != utf8.RuneError { + n = len(utf16.Encode([]rune{r})) + } + if units+n > char { + break // inside a surrogate pair: stay before the character + } + units += n + i += size + } + return i +} + +// charColumn converts a byte count within line to a character count. +func charColumn(line []byte, col int, enc encoding) int { + col = max(0, min(col, len(line))) + if enc == utf8Bytes { + return col + } + units := 0 + for i := 0; i < col; { + r, size := utf8.DecodeRune(line[i:]) + if r >= 0x10000 && r != utf8.RuneError { + units += 2 + } else { + units++ + } + i += size + } + return units +} + +// positionOf converts a 0-based line and byte column of text to a position. +func positionOf(text []byte, line, col int, enc encoding) Position { + start, end, ok := lineBounds(text, line) + if !ok { + return Position{Line: line, Character: 0} + } + return Position{Line: line, Character: charColumn(text[start:end], col, enc)} +} + +// nameRange is the range of the name at the 0-based line and byte column of +// text: the identifier there (an annotation's '@' included), or an empty +// range when there is none. +func nameRange(text []byte, line, col int, enc encoding) Range { + start, end, ok := lineBounds(text, line) + if !ok { + p := Position{Line: line} + return Range{Start: p, End: p} + } + l := text[start:end] + col = max(0, min(col, len(l))) + e := col + if e < len(l) && l[e] == '@' { + e++ + } + for e < len(l) && identByte(l[e]) { + e++ + } + return Range{ + Start: Position{Line: line, Character: charColumn(l, col, enc)}, + End: Position{Line: line, Character: charColumn(l, e, enc)}, + } +} + +// identByte matches codenav's lexer: ASCII letters, digits, '_', '$' and any +// byte of a multi-byte UTF-8 sequence. +func identByte(c byte) bool { + return c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '_' || c == '$' || c >= 0x80 +} diff --git a/internal/lsp/position_test.go b/internal/lsp/position_test.go new file mode 100644 index 0000000..bfa7eca --- /dev/null +++ b/internal/lsp/position_test.go @@ -0,0 +1,98 @@ +package lsp + +import ( + "path/filepath" + "runtime" + "testing" +) + +func TestPositionsInUTF16AndUTF8(t *testing.T) { + // "名" is 3 bytes and 1 UTF-16 unit; "😀" is 4 bytes and 2 units. + text := []byte("class A {}\r\nString 名 = \"😀\"; int x;\nlast") + xByte := len("String 名 = \"😀\"; int ") + cases := []struct { + enc encoding + char int + }{ + {utf16Units, len([]rune("String 名 = \"")) + 2 + len("\"; int ")}, + {utf8Bytes, xByte}, + } + for _, c := range cases { + off := offsetOf(text, Position{Line: 1, Character: c.char}, c.enc) + if got := string(text[off : off+1]); got != "x" { + t.Errorf("%s: offset %d is %q, want x", c.enc, off, got) + } + if p := positionOf(text, 1, xByte, c.enc); p != (Position{Line: 1, Character: c.char}) { + t.Errorf("%s: positionOf = %+v, want character %d", c.enc, p, c.char) + } + } + // A character past the line end clamps to it, before the "\r\n". + if off := offsetOf(text, Position{Line: 0, Character: 99}, utf16Units); off != len("class A {}") { + t.Errorf("clamped offset = %d", off) + } + // A line past the end clamps to the end of the text. + if off := offsetOf(text, Position{Line: 9, Character: 0}, utf16Units); off != len(text) { + t.Errorf("past-end offset = %d, want %d", off, len(text)) + } + // Inside a surrogate pair the position stays before the character. + emoji := len([]rune("String 名 = \"")) + if off := offsetOf(text, Position{Line: 1, Character: emoji + 1}, utf16Units); string(text[off:off+4]) != "😀" { + t.Errorf("offset inside a surrogate pair = %q", text[off:]) + } +} + +func TestNameRange(t *testing.T) { + text := []byte("@Data\nclass 名前 { int x; }") + if r := nameRange(text, 0, 0, utf16Units); r.Start.Character != 0 || r.End.Character != 5 { + t.Errorf("annotation range = %+v, want 0..5", r) + } + if r := nameRange(text, 1, len("class "), utf16Units); r.Start.Character != 6 || r.End.Character != 8 { + t.Errorf("non-ASCII identifier range = %+v, want 6..8", r) + } + if r := nameRange(text, 1, len("class "), utf8Bytes); r.Start.Character != 6 || r.End.Character != 12 { + t.Errorf("UTF-8 range = %+v, want 6..12", r) + } + if r := nameRange(text, 5, 0, utf16Units); r.Start != r.End { + t.Errorf("range past the end = %+v, want empty", r) + } +} + +func TestIncrementalChanges(t *testing.T) { + d := &document{text: []byte("class A {\n int x;\n}\n")} + changes := []contentChange{ + {Range: &Range{Start: Position{1, 6}, End: Position{1, 7}}, Text: "count"}, + {Range: &Range{Start: Position{0, 6}, End: Position{0, 7}}, Text: "名"}, + {Range: &Range{Start: Position{2, 1}, End: Position{2, 1}}, Text: " // end"}, + } + if err := d.apply(changes, utf16Units); err != nil { + t.Fatal(err) + } + if want := "class 名 {\n int count;\n} // end\n"; string(d.text) != want { + t.Errorf("text = %q, want %q", d.text, want) + } + if err := d.apply([]contentChange{{Text: "replaced"}}, utf16Units); err != nil || string(d.text) != "replaced" { + t.Errorf("full change = %q, %v", d.text, err) + } + bad := []contentChange{{Range: &Range{Start: Position{0, 5}, End: Position{0, 1}}, Text: ""}} + if err := d.apply(bad, utf16Units); err == nil { + t.Error("a reversed range was accepted") + } +} + +func TestURIRoundTrip(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "my src", "名前", "A#1.java") + u := uriOf(p) + back, err := pathOf(u) + if err != nil || back != filepath.Clean(p) { + t.Errorf("pathOf(%s) = %q, %v; want %q", u, back, err, p) + } + if runtime.GOOS == "windows" { + if got, err := pathOf("file:///c%3A/x/A.java"); err != nil || got != `c:\x\A.java` { + t.Errorf("encoded drive letter = %q, %v", got, err) + } + } + if _, err := pathOf("jdt://contents/x"); err == nil { + t.Error("a non-file URI was accepted") + } +} diff --git a/internal/lsp/server.go b/internal/lsp/server.go new file mode 100644 index 0000000..311f37a --- /dev/null +++ b/internal/lsp/server.go @@ -0,0 +1,707 @@ +// Package lsp serves go to definition, find references and the document +// outline for Java over the Language Server Protocol, on top of package +// workspace. It speaks only what those features need: document +// synchronization, the three requests, workspace folders and cancellation. +package lsp + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "sync" + + "github.com/Lithe-IDEA/lithe-plugin-java-navigation/internal/codenav" + "github.com/Lithe-IDEA/lithe-plugin-java-navigation/internal/jdk" + "github.com/Lithe-IDEA/lithe-plugin-java-navigation/internal/workspace" +) + +// Options configure a Server. +type Options struct { + Name, Version string + // State is the writable directory for extracted JDK sources. + State string + // FindJDK locates the JDK whose sources are used; nil disables them. + FindJDK func(context.Context) (jdk.JDK, bool) + // Workspace overrides workspace options other than Root, for tests. + Workspace workspace.Options + // Logf writes a diagnostic line; nil discards it. + Logf func(format string, args ...any) +} + +// Server is one LSP session. +type Server struct { + opt Options + conn *conn + + mu sync.Mutex + initialized bool + shutdown bool + enc encoding + hierarchical bool // the client takes DocumentSymbol trees + docs map[string]*document + folders []*workspace.Workspace + inflight map[string]context.CancelFunc + wg sync.WaitGroup +} + +// New returns a server that reads requests from r and writes to w. +func New(r io.Reader, w io.Writer, opt Options) *Server { + if opt.Logf == nil { + opt.Logf = func(string, ...any) {} + } + return &Server{opt: opt, conn: newConn(r, w), docs: map[string]*document{}, inflight: map[string]context.CancelFunc{}} +} + +// ErrExitWithoutShutdown is Run's result for an exit notification that was +// not preceded by shutdown; the process should then exit with status 1. +var ErrExitWithoutShutdown = errors.New("exit without shutdown") + +// Run serves until the client sends exit or closes the stream. +func (s *Server) Run() error { + defer func() { + // Queries still running are answered as cancelled before Run returns. + s.mu.Lock() + for _, cancel := range s.inflight { + cancel() + } + s.mu.Unlock() + s.wg.Wait() + }() + for { + m, err := s.conn.read() + if errors.Is(err, io.EOF) { + return nil + } + if errors.Is(err, errParse) { + s.conn.replyError(nil, codeParseError, "invalid JSON") + continue + } + if err != nil { + return err + } + if m.Method == "exit" { + s.mu.Lock() + clean := s.shutdown + s.mu.Unlock() + if !clean { + return ErrExitWithoutShutdown + } + return nil + } + if m.Method == "" { + continue // a response; this server sends no requests + } + s.dispatch(m) + } +} + +// dispatch handles a message in arrival order. Notifications change the +// documents synchronously, so a later request sees their effect; a query +// takes a snapshot of its document here and is answered on its own +// goroutine. +func (s *Server) dispatch(m *message) { + s.mu.Lock() + ready, down := s.initialized, s.shutdown + s.mu.Unlock() + if m.Method == "initialize" { + s.reply(m, s.initialize(m.Params)) + return + } + if !ready { + if m.isRequest() { + s.conn.replyError(m.ID, codeServerNotInit, "server not initialized") + } + return + } + if down && m.isRequest() { + s.conn.replyError(m.ID, codeInvalidRequest, "server is shutting down") + return + } + switch m.Method { + case "initialized": + case "shutdown": + s.mu.Lock() + s.shutdown = true + s.mu.Unlock() + s.conn.reply(m.ID, nil) + case "textDocument/didOpen": + s.didOpen(m.Params) + case "textDocument/didChange": + s.didChange(m.Params) + case "textDocument/didClose": + s.didClose(m.Params) + case "textDocument/didSave": + case "workspace/didChangeWatchedFiles": + for _, w := range s.workspaces() { + w.Invalidate() + } + case "workspace/didChangeWorkspaceFolders": + s.changeFolders(m.Params) + case "workspace/didChangeConfiguration": + case "$/cancelRequest": + s.cancel(m.Params) + case "textDocument/definition": + s.query(m, s.definition) + case "textDocument/references": + s.query(m, s.references) + case "textDocument/documentSymbol": + s.query(m, s.documentSymbol) + default: + if m.isRequest() { + s.conn.replyError(m.ID, codeMethodNotFound, "method not supported: "+m.Method) + } + } +} + +// reply answers m with result, or with the error result carries. +func (s *Server) reply(m *message, result any) { + if err, ok := result.(*rpcError); ok { + s.conn.replyError(m.ID, err.code, err.msg) + return + } + s.conn.reply(m.ID, result) +} + +type rpcError struct { + code int + msg string +} + +func (e *rpcError) Error() string { return e.msg } + +// handler answers a query from a snapshot taken in arrival order. +type handler func(params json.RawMessage) (func(ctx context.Context) (any, error), error) + +func (s *Server) query(m *message, h handler) { + run, err := h(m.Params) + if err != nil { + s.conn.replyError(m.ID, codeInvalidParams, err.Error()) + return + } + ctx, cancel := context.WithCancel(context.Background()) + key := string(m.ID) + s.mu.Lock() + s.inflight[key] = cancel + s.mu.Unlock() + s.wg.Add(1) + go func() { + defer s.wg.Done() + defer func() { + s.mu.Lock() + delete(s.inflight, key) + s.mu.Unlock() + cancel() + }() + result, err := run(ctx) + switch { + case ctx.Err() != nil: + s.conn.replyError(m.ID, codeRequestCancelled, "request cancelled") + case err != nil: + s.opt.Logf("%s: %v", m.Method, err) + s.conn.replyError(m.ID, codeInternalError, err.Error()) + default: + s.conn.reply(m.ID, result) + } + }() +} + +func (s *Server) cancel(params json.RawMessage) { + var p struct { + ID json.RawMessage `json:"id"` + } + if json.Unmarshal(params, &p) != nil { + return + } + s.mu.Lock() + cancel := s.inflight[string(p.ID)] + s.mu.Unlock() + if cancel != nil { + cancel() + } +} + +// --- initialize --- + +type workspaceFolder struct { + URI string `json:"uri"` + Name string `json:"name"` +} + +func (s *Server) initialize(params json.RawMessage) any { + var p struct { + RootURI string `json:"rootUri"` + RootPath string `json:"rootPath"` + WorkspaceFolders []workspaceFolder `json:"workspaceFolders"` + Capabilities struct { + General struct { + PositionEncodings []string `json:"positionEncodings"` + } `json:"general"` + TextDocument struct { + DocumentSymbol struct { + Hierarchical bool `json:"hierarchicalDocumentSymbolSupport"` + } `json:"documentSymbol"` + } `json:"textDocument"` + } `json:"capabilities"` + } + if err := json.Unmarshal(params, &p); err != nil { + return &rpcError{codeInvalidParams, "initialize: " + err.Error()} + } + s.mu.Lock() + defer s.mu.Unlock() + if s.initialized { + return &rpcError{codeInvalidRequest, "initialize sent twice"} + } + s.enc = utf16Units + for _, e := range p.Capabilities.General.PositionEncodings { + if e == "utf-8" { + s.enc = utf8Bytes + break + } + } + s.hierarchical = p.Capabilities.TextDocument.DocumentSymbol.Hierarchical + var roots []string + for _, f := range p.WorkspaceFolders { + if path, err := pathOf(f.URI); err == nil { + roots = append(roots, path) + } + } + if len(roots) == 0 { + if path, err := pathOf(p.RootURI); err == nil { + roots = append(roots, path) + } else if p.RootPath != "" { + roots = append(roots, filepath.Clean(p.RootPath)) + } + } + for _, r := range roots { + s.folders = append(s.folders, s.newWorkspace(r)) + } + s.initialized = true + s.opt.Logf("initialized: %d folder(s), position encoding %s", len(roots), s.enc) + return map[string]any{ + "capabilities": map[string]any{ + "positionEncoding": s.enc.String(), + "textDocumentSync": map[string]any{ + "openClose": true, + "change": 2, // incremental + }, + "definitionProvider": true, + "referencesProvider": true, + "documentSymbolProvider": true, + "workspace": map[string]any{ + "workspaceFolders": map[string]any{"supported": true, "changeNotifications": true}, + }, + }, + "serverInfo": map[string]any{"name": s.opt.Name, "version": s.opt.Version}, + } +} + +func (s *Server) newWorkspace(root string) *workspace.Workspace { + o := s.opt.Workspace + o.Root = root + o.State = s.opt.State + o.FindJDK = s.opt.FindJDK + if o.Logf == nil { + o.Logf = s.opt.Logf + } + return workspace.New(o) +} + +func (s *Server) changeFolders(params json.RawMessage) { + var p struct { + Event struct { + Added []workspaceFolder `json:"added"` + Removed []workspaceFolder `json:"removed"` + } `json:"event"` + } + if json.Unmarshal(params, &p) != nil { + return + } + s.mu.Lock() + defer s.mu.Unlock() + for _, f := range p.Event.Removed { + path, err := pathOf(f.URI) + if err != nil { + continue + } + kept := s.folders[:0] + for _, w := range s.folders { + if w.Root() != path { + kept = append(kept, w) + } + } + s.folders = kept + } + for _, f := range p.Event.Added { + if path, err := pathOf(f.URI); err == nil { + s.folders = append(s.folders, s.newWorkspace(path)) + } + } +} + +func (s *Server) workspaces() []*workspace.Workspace { + s.mu.Lock() + defer s.mu.Unlock() + return append([]*workspace.Workspace(nil), s.folders...) +} + +// locate returns the workspace that answers for path and how it sees the +// file: the innermost folder containing it, or the one whose JDK sources it +// is a copy of. +func (s *Server) locate(path string) (*workspace.Workspace, workspace.Doc) { + var best *workspace.Workspace + var bestDoc workspace.Doc + for _, w := range s.workspaces() { + d := w.DocOf(path) + if d.Library != "" { + return w, d + } + if d.Rel != "" && (best == nil || len(w.Root()) > len(best.Root())) { + best, bestDoc = w, d + } + } + return best, bestDoc +} + +// --- document synchronization --- + +type textDocumentItem struct { + URI string `json:"uri"` + Version int `json:"version"` + Text string `json:"text"` +} + +type textDocumentIdentifier struct { + URI string `json:"uri"` +} + +func (s *Server) didOpen(params json.RawMessage) { + var p struct { + TextDocument textDocumentItem `json:"textDocument"` + } + if json.Unmarshal(params, &p) != nil { + return + } + path, err := pathOf(p.TextDocument.URI) + if err != nil { + return + } + d := &document{uri: p.TextDocument.URI, path: path, version: p.TextDocument.Version, text: []byte(p.TextDocument.Text)} + s.mu.Lock() + s.docs[d.uri] = d + s.mu.Unlock() + if w, doc := s.locate(path); w != nil { + w.Open(doc, d.text) + } +} + +func (s *Server) didChange(params json.RawMessage) { + var p struct { + TextDocument struct { + URI string `json:"uri"` + Version int `json:"version"` + } `json:"textDocument"` + ContentChanges []contentChange `json:"contentChanges"` + } + if json.Unmarshal(params, &p) != nil { + return + } + s.mu.Lock() + d := s.docs[p.TextDocument.URI] + enc := s.enc + var text []byte + if d != nil { + if err := d.apply(p.ContentChanges, enc); err != nil { + s.opt.Logf("didChange %s: %v", d.uri, err) + } + d.version = p.TextDocument.Version + text = d.text + } + s.mu.Unlock() + if d == nil { + return + } + if w, doc := s.locate(d.path); w != nil { + w.Open(doc, text) + } +} + +func (s *Server) didClose(params json.RawMessage) { + var p struct { + TextDocument textDocumentIdentifier `json:"textDocument"` + } + if json.Unmarshal(params, &p) != nil { + return + } + s.mu.Lock() + d := s.docs[p.TextDocument.URI] + delete(s.docs, p.TextDocument.URI) + s.mu.Unlock() + if d == nil { + return + } + if w, doc := s.locate(d.path); w != nil { + w.Close(doc) + } +} + +// snapshot returns the content of uri: the open document's text, otherwise +// the file on disk. +func (s *Server) snapshot(uri string) (path string, text []byte, err error) { + s.mu.Lock() + d := s.docs[uri] + if d != nil { + path, text = d.path, d.text + } + s.mu.Unlock() + if d != nil { + return path, text, nil + } + path, err = pathOf(uri) + if err != nil { + return "", nil, err + } + text, err = os.ReadFile(path) + if err != nil && !errors.Is(err, os.ErrNotExist) { + return "", nil, err + } + return path, text, nil +} + +// --- queries --- + +type textDocumentPosition struct { + TextDocument textDocumentIdentifier `json:"textDocument"` + Position Position `json:"position"` +} + +// Location is a range in a file. +type Location struct { + URI string `json:"uri"` + Range Range `json:"range"` +} + +func (s *Server) encoding() encoding { + s.mu.Lock() + defer s.mu.Unlock() + return s.enc +} + +// prepare snapshots a position query; covered is false for a document +// navigation does not answer. +func (s *Server) prepare(p textDocumentPosition) (w *workspace.Workspace, doc workspace.Doc, text []byte, off int, err error) { + path, text, err := s.snapshot(p.TextDocument.URI) + if err != nil { + return nil, doc, nil, 0, err + } + w, doc = s.locate(path) + return w, doc, text, offsetOf(text, p.Position, s.encoding()), nil +} + +func (s *Server) definition(params json.RawMessage) (func(context.Context) (any, error), error) { + var p textDocumentPosition + if err := json.Unmarshal(params, &p); err != nil { + return nil, err + } + w, doc, text, off, err := s.prepare(p) + if err != nil { + return nil, err + } + return func(ctx context.Context) (any, error) { + if w == nil || !doc.Covered() { + return nil, nil + } + t, ok, err := w.Definition(ctx, doc, text, off) + if err != nil || !ok { + return nil, err + } + loc, err := s.location(w, t) + if err != nil { + return nil, err + } + return loc, nil + }, nil +} + +func (s *Server) references(params json.RawMessage) (func(context.Context) (any, error), error) { + var p struct { + textDocumentPosition + Context struct { + IncludeDeclaration bool `json:"includeDeclaration"` + } `json:"context"` + } + if err := json.Unmarshal(params, &p); err != nil { + return nil, err + } + w, doc, text, off, err := s.prepare(p.textDocumentPosition) + if err != nil { + return nil, err + } + return func(ctx context.Context) (any, error) { + out := []Location{} + if w == nil || !doc.Covered() { + return out, nil + } + targets, more, err := w.References(ctx, doc, text, off, p.Context.IncludeDeclaration) + if err != nil { + return nil, err + } + if more { + s.opt.Logf("references: stopped at %d", workspace.MaxReferences) + } + texts := map[string][]byte{} + for _, t := range targets { + loc, err := s.locationCached(w, t, texts) + if err != nil { + continue + } + out = append(out, loc) + } + return out, nil + }, nil +} + +// location converts a target to a protocol location, reading the target's +// text to measure its columns. +func (s *Server) location(w *workspace.Workspace, t workspace.Target) (Location, error) { + return s.locationCached(w, t, nil) +} + +func (s *Server) locationCached(w *workspace.Workspace, t workspace.Target, texts map[string][]byte) (Location, error) { + text, ok := texts[t.File] + if !ok { + var err error + if text, err = s.textOf(w, t.File); err != nil { + return Location{}, err + } + if texts != nil { + texts[t.File] = text + } + } + return Location{URI: uriOf(t.File), Range: nameRange(text, t.Line, t.Column, s.encoding())}, nil +} + +// textOf is the content of file as the editor sees it. +func (s *Server) textOf(w *workspace.Workspace, file string) ([]byte, error) { + s.mu.Lock() + for _, d := range s.docs { + if d.path == file { + text := d.text + s.mu.Unlock() + return text, nil + } + } + s.mu.Unlock() + return w.Text(file) +} + +// documentSymbol answers with the declarations of a Java file, as a tree when +// the client takes one. +func (s *Server) documentSymbol(params json.RawMessage) (func(context.Context) (any, error), error) { + var p struct { + TextDocument textDocumentIdentifier `json:"textDocument"` + } + if err := json.Unmarshal(params, &p); err != nil { + return nil, err + } + path, text, err := s.snapshot(p.TextDocument.URI) + if err != nil { + return nil, err + } + s.mu.Lock() + hierarchical, enc := s.hierarchical, s.enc + s.mu.Unlock() + uri := p.TextDocument.URI + return func(context.Context) (any, error) { + if !codenav.Supports(path) { + return []any{}, nil + } + syms := codenav.Symbols(text) + if hierarchical { + return documentSymbols(text, syms, enc), nil + } + var flat []symbolInformation + flattenSymbols(text, uri, "", syms, enc, &flat) + if flat == nil { + flat = []symbolInformation{} + } + return flat, nil + }, nil +} + +type documentSymbolJSON struct { + Name string `json:"name"` + Detail string `json:"detail,omitempty"` + Kind int `json:"kind"` + Range Range `json:"range"` + SelectionRange Range `json:"selectionRange"` + Children []documentSymbolJSON `json:"children,omitempty"` +} + +type symbolInformation struct { + Name string `json:"name"` + Kind int `json:"kind"` + Location Location `json:"location"` + ContainerName string `json:"containerName,omitempty"` +} + +func documentSymbols(text []byte, syms []codenav.Symbol, enc encoding) []documentSymbolJSON { + out := make([]documentSymbolJSON, 0, len(syms)) + for _, sym := range syms { + r := nameRange(text, sym.Line, sym.Column, enc) + out = append(out, documentSymbolJSON{ + Name: sym.Name, Detail: sym.Detail, Kind: symbolKind(sym.Kind), + Range: r, SelectionRange: r, Children: documentSymbols(text, sym.Children, enc), + }) + } + return out +} + +func flattenSymbols(text []byte, uri, container string, syms []codenav.Symbol, enc encoding, out *[]symbolInformation) { + for _, sym := range syms { + *out = append(*out, symbolInformation{ + Name: sym.Name, Kind: symbolKind(sym.Kind), ContainerName: container, + Location: Location{URI: uri, Range: nameRange(text, sym.Line, sym.Column, enc)}, + }) + name := sym.Name + if container != "" { + name = container + "." + sym.Name + } + flattenSymbols(text, uri, name, sym.Children, enc, out) + } +} + +// symbolKind maps to the protocol's SymbolKind numbers. +func symbolKind(k codenav.SymbolKind) int { + switch k { + case codenav.SymbolInterface, codenav.SymbolAnnotation: + return 11 + case codenav.SymbolEnum: + return 10 + case codenav.SymbolRecord: + return 23 // Struct + case codenav.SymbolMethod: + return 6 + case codenav.SymbolConstructor: + return 9 + case codenav.SymbolField: + return 8 + case codenav.SymbolEnumConstant: + return 22 + default: + return 5 // Class + } +} + +// String describes the server for a log line. +func (s *Server) String() string { + var roots []string + for _, w := range s.workspaces() { + roots = append(roots, w.Root()) + } + return fmt.Sprintf("%s %s [%s]", s.opt.Name, s.opt.Version, strings.Join(roots, ", ")) +} diff --git a/internal/lsp/server_test.go b/internal/lsp/server_test.go new file mode 100644 index 0000000..74a75ae --- /dev/null +++ b/internal/lsp/server_test.go @@ -0,0 +1,253 @@ +package lsp + +import ( + "bufio" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/Lithe-IDEA/lithe-plugin-java-navigation/internal/workspace" +) + +// replyTimeout bounds every wait for the server in these tests. +const replyTimeout = 10 * time.Second + +// client drives a Server over in-memory pipes. +type client struct { + t *testing.T + in *io.PipeWriter + out *conn + nextID int + msgs chan json.RawMessage + done chan error +} + +func startServer(t *testing.T, root string) *client { + t.Helper() + inR, inW := io.Pipe() + outR, outW := io.Pipe() + s := New(inR, outW, Options{ + Name: "java-navigation", Version: "test", State: filepath.Join(t.TempDir(), "state"), + Workspace: workspace.Options{Freshness: -1}, + Logf: t.Logf, + }) + c := &client{t: t, in: inW, out: newConn(outR, io.Discard), msgs: make(chan json.RawMessage, 64), done: make(chan error, 1)} + go func() { + err := s.Run() + outW.Close() + c.done <- err + }() + go func() { + r := bufio.NewReader(outR) + c.out.r = r + for { + m, err := c.readRaw() + if err != nil { + close(c.msgs) + return + } + c.msgs <- m + } + }() + t.Cleanup(func() { + inW.Close() + select { + case <-c.done: + case <-time.After(replyTimeout): + t.Error("server did not stop") + } + }) + return c +} + +func (c *client) readRaw() (json.RawMessage, error) { + var n int + for { + line, err := c.out.r.ReadString('\n') + if err != nil { + return nil, err + } + line = strings.TrimRight(line, "\r\n") + if line == "" { + break + } + if v, ok := strings.CutPrefix(line, "Content-Length: "); ok { + fmt.Sscan(v, &n) + } + } + b := make([]byte, n) + _, err := io.ReadFull(c.out.r, b) + return b, err +} + +func (c *client) send(v any) { + c.t.Helper() + b, _ := json.Marshal(v) + if _, err := fmt.Fprintf(c.in, "Content-Length: %d\r\n\r\n%s", len(b), b); err != nil { + c.t.Fatal(err) + } +} + +func (c *client) notify(method string, params any) { + c.send(map[string]any{"jsonrpc": "2.0", "method": method, "params": params}) +} + +// call sends a request and returns its result, failing on an error response. +func (c *client) call(method string, params any, result any) { + c.t.Helper() + c.nextID++ + id := c.nextID + c.send(map[string]any{"jsonrpc": "2.0", "id": id, "method": method, "params": params}) + deadline := time.After(replyTimeout) + for { + select { + case raw, ok := <-c.msgs: + if !ok { + c.t.Fatalf("%s: server closed the stream", method) + } + var r struct { + ID int `json:"id"` + Result json.RawMessage `json:"result"` + Error *responseError `json:"error"` + } + if json.Unmarshal(raw, &r) != nil || r.ID != id { + continue + } + if r.Error != nil { + c.t.Fatalf("%s: error %d %s", method, r.Error.Code, r.Error.Message) + } + if result != nil { + if err := json.Unmarshal(r.Result, result); err != nil { + c.t.Fatalf("%s: %v in %s", method, err, r.Result) + } + } + return + case <-deadline: + c.t.Fatalf("%s: no reply within %v", method, replyTimeout) + } + } +} + +func TestServerSession(t *testing.T) { + root := t.TempDir() + files := map[string]string{ + "src/a/Shape.java": "package a;\n\npublic interface Shape {\n double area();\n}\n", + "src/a/Circle.java": `package a; + +public class Circle implements Shape { + private final double r; + + public Circle(double r) { this.r = r; } + + @Override + public double area() { return Math.PI * r * r; } + + enum Unit { MM, CM } +} +`, + "src/b/Main.java": "package b;\n\nimport a.Circle;\n\nclass Main {\n double f() { return new Circle(1).area(); }\n}\n", + } + for rel, body := range files { + p := filepath.Join(root, filepath.FromSlash(rel)) + os.MkdirAll(filepath.Dir(p), 0o755) + os.WriteFile(p, []byte(body), 0o644) + } + c := startServer(t, root) + + var init struct { + Capabilities struct { + PositionEncoding string `json:"positionEncoding"` + DefinitionProvider bool `json:"definitionProvider"` + ReferencesProvider bool `json:"referencesProvider"` + } `json:"capabilities"` + } + c.call("initialize", map[string]any{ + "processId": nil, "rootUri": uriOf(root), + "capabilities": map[string]any{"textDocument": map[string]any{"documentSymbol": map[string]any{"hierarchicalDocumentSymbolSupport": true}}}, + }, &init) + if init.Capabilities.PositionEncoding != "utf-16" || !init.Capabilities.DefinitionProvider || !init.Capabilities.ReferencesProvider { + t.Fatalf("capabilities = %+v", init.Capabilities) + } + c.notify("initialized", map[string]any{}) + + mainPath := filepath.Join(root, "src/b/Main.java") + mainURI := uriOf(mainPath) + c.notify("textDocument/didOpen", map[string]any{"textDocument": map[string]any{ + "uri": mainURI, "languageId": "java", "version": 1, "text": files["src/b/Main.java"]}}) + // An unsaved edit: "area()" moves right by " " on line 5. + c.notify("textDocument/didChange", map[string]any{ + "textDocument": map[string]any{"uri": mainURI, "version": 2}, + "contentChanges": []any{map[string]any{"range": Range{Start: Position{5, 24}, End: Position{5, 24}}, "text": " "}}, + }) + + // "area" in "new Circle(1).area()" goes to Circle.area. + var loc *Location + c.call("textDocument/definition", map[string]any{ + "textDocument": map[string]any{"uri": mainURI}, "position": Position{Line: 5, Character: 41}, + }, &loc) + if loc == nil || loc.URI != uriOf(filepath.Join(root, "src/a/Circle.java")) || loc.Range != (Range{Position{8, 18}, Position{8, 22}}) { + t.Fatalf("definition = %+v", loc) + } + + // References of Shape.area: Circle.area overrides it but is a separate + // declaration, so only the interface's own declaration is listed. + var refs []Location + shapeURI := uriOf(filepath.Join(root, "src/a/Shape.java")) + c.call("textDocument/references", map[string]any{ + "textDocument": map[string]any{"uri": shapeURI}, "position": Position{3, 12}, + "context": map[string]any{"includeDeclaration": true}, + }, &refs) + if len(refs) != 1 || refs[0].URI != shapeURI { + t.Errorf("references of Shape.area = %+v", refs) + } + // References of the Circle class include the import and constructor call. + c.call("textDocument/references", map[string]any{ + "textDocument": map[string]any{"uri": uriOf(filepath.Join(root, "src/a/Circle.java"))}, "position": Position{2, 14}, + "context": map[string]any{"includeDeclaration": false}, + }, &refs) + if len(refs) != 1 || refs[0].URI != mainURI || refs[0].Range.Start.Line != 5 { + t.Errorf("references of Circle = %+v", refs) + } + + // The outline of Circle, as a tree. + var syms []struct { + Name string `json:"name"` + Kind int `json:"kind"` + Children []struct { + Name string `json:"name"` + Kind int `json:"kind"` + } `json:"children"` + } + c.call("textDocument/documentSymbol", map[string]any{"textDocument": map[string]any{"uri": uriOf(filepath.Join(root, "src/a/Circle.java"))}}, &syms) + var outline []string + for _, s := range syms { + outline = append(outline, fmt.Sprintf("%s/%d", s.Name, s.Kind)) + for _, ch := range s.Children { + outline = append(outline, fmt.Sprintf(" %s/%d", ch.Name, ch.Kind)) + } + } + if got, want := strings.Join(outline, ","), "Circle/5, r/8, Circle/9, area/6, Unit/10"; got != want { + t.Errorf("outline = %s, want %s", got, want) + } + + // An unsupported request is refused, not ignored. + c.nextID++ + c.send(map[string]any{"jsonrpc": "2.0", "id": c.nextID, "method": "textDocument/hover", "params": map[string]any{}}) + + c.call("shutdown", nil, nil) + c.notify("exit", nil) + select { + case err := <-c.done: + if err != nil { + t.Errorf("Run = %v", err) + } + c.done <- nil // for Cleanup + case <-time.After(replyTimeout): + t.Fatal("server did not exit") + } +} diff --git a/internal/lsp/uri.go b/internal/lsp/uri.go new file mode 100644 index 0000000..42f14fd --- /dev/null +++ b/internal/lsp/uri.go @@ -0,0 +1,48 @@ +package lsp + +import ( + "fmt" + "net/url" + "path/filepath" + "runtime" + "strings" +) + +// pathOf converts a file URI to an absolute path of this system. +func pathOf(uri string) (string, error) { + u, err := url.Parse(uri) + if err != nil { + return "", err + } + if !strings.EqualFold(u.Scheme, "file") { + return "", fmt.Errorf("not a file URI: %s", uri) + } + p := u.Path + if runtime.GOOS == "windows" { + if u.Host != "" && !strings.EqualFold(u.Host, "localhost") { + p = `\\` + u.Host + filepath.FromSlash(p) // UNC path + return p, nil + } + // file:///C:/x has the path /C:/x. + if len(p) >= 3 && p[0] == '/' && p[2] == ':' { + p = p[1:] + } + } + return filepath.Clean(filepath.FromSlash(p)), nil +} + +// uriOf converts an absolute path to a file URI. +func uriOf(path string) string { + p := filepath.ToSlash(path) + if runtime.GOOS == "windows" { + if strings.HasPrefix(p, "//") { + rest := strings.TrimPrefix(p, "//") + host, tail, _ := strings.Cut(rest, "/") + return (&url.URL{Scheme: "file", Host: host, Path: "/" + tail}).String() + } + if !strings.HasPrefix(p, "/") { + p = "/" + p + } + } + return (&url.URL{Scheme: "file", Path: p}).String() +} diff --git a/internal/workspace/files.go b/internal/workspace/files.go new file mode 100644 index 0000000..2482413 --- /dev/null +++ b/internal/workspace/files.go @@ -0,0 +1,93 @@ +package workspace + +import ( + "bytes" + "context" + "io/fs" + "os/exec" + "path/filepath" + "strings" + "time" +) + +// gitTimeout bounds listing the files through Git. +const gitTimeout = 30 * time.Second + +// skippedDirs are directory names a walk does not enter: build outputs, +// dependency caches and tool state that hold no hand-written sources. +var skippedDirs = map[string]bool{ + "target": true, "build": true, "out": true, "bin": true, "node_modules": true, +} + +// ListFiles lists the files under root as slash-separated relative paths. +// 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. +func ListFiles(ctx context.Context, root string) ([]string, error) { + if files, ok := gitFiles(ctx, root); ok { + return files, nil + } + return walkFiles(ctx, root) +} + +// gitFiles lists root's files through Git; ok is false when root is not in a +// working tree or Git is unavailable. +func gitFiles(ctx context.Context, root string) ([]string, bool) { + git, err := exec.LookPath("git") + if err != nil { + return nil, false + } + ctx, cancel := context.WithTimeout(ctx, gitTimeout) + defer cancel() + cmd := exec.CommandContext(ctx, git, "-c", "core.quotePath=false", "ls-files", "-z", "--cached", "--others", "--exclude-standard") + cmd.Dir = root + hideWindow(cmd) + out, err := cmd.Output() + if err != nil { + return nil, false + } + // A file with merge conflicts is listed once per stage. + seen := map[string]bool{} + var files []string + for _, b := range bytes.Split(out, []byte{0}) { + if len(b) > 0 && !seen[string(b)] { + seen[string(b)] = true + files = append(files, string(b)) + } + } + return files, true +} + +// walkFiles lists root's files by walking the directory tree. +func walkFiles(ctx context.Context, root string) ([]string, error) { + var files []string + err := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error { + if err != nil { + if p == root { + return err + } + return nil // an unreadable directory holds nothing we can index + } + if ctx.Err() != nil { + return ctx.Err() + } + name := d.Name() + if d.IsDir() { + if p != root && (strings.HasPrefix(name, ".") || skippedDirs[name]) { + return filepath.SkipDir + } + return nil + } + if !d.Type().IsRegular() { + return nil + } + rel, err := filepath.Rel(root, p) + if err != nil { + return nil + } + files = append(files, filepath.ToSlash(rel)) + return nil + }) + return files, err +} diff --git a/internal/workspace/hidewindow_other.go b/internal/workspace/hidewindow_other.go new file mode 100644 index 0000000..06df3c8 --- /dev/null +++ b/internal/workspace/hidewindow_other.go @@ -0,0 +1,7 @@ +//go:build !windows + +package workspace + +import "os/exec" + +func hideWindow(*exec.Cmd) {} diff --git a/internal/workspace/hidewindow_windows.go b/internal/workspace/hidewindow_windows.go new file mode 100644 index 0000000..780971b --- /dev/null +++ b/internal/workspace/hidewindow_windows.go @@ -0,0 +1,14 @@ +package workspace + +import ( + "os/exec" + "syscall" +) + +// createNoWindow keeps a console program started by this windowless server +// from opening a console window. +const createNoWindow = 0x08000000 + +func hideWindow(cmd *exec.Cmd) { + cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true, CreationFlags: createNoWindow} +} diff --git a/internal/workspace/workspace.go b/internal/workspace/workspace.go new file mode 100644 index 0000000..e078df8 --- /dev/null +++ b/internal/workspace/workspace.go @@ -0,0 +1,564 @@ +// Package workspace owns the declaration index of one project folder and +// answers go to definition, find references and outline queries over it. +// +// The index is a cache of declarations read from the folder (adapted from +// Viff's ADR 0040); it only speeds up a query and never replaces the files: +// +// - It is built on the first query, not when the server starts, so a +// project nobody navigates costs nothing. +// - A query whose index is older than the freshness window rescans first: +// the file list is read again and only files whose size or modification +// time changed are parsed again, so edits made by other tools show up. +// - Open documents are indexed from the text the editor holds, not from the +// copy on disk, and the document a query comes from is parsed from that +// 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. +package workspace + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "runtime" + "sort" + "strings" + "sync" + "time" + + "github.com/Lithe-IDEA/lithe-plugin-java-navigation/internal/codenav" + "github.com/Lithe-IDEA/lithe-plugin-java-navigation/internal/jdk" +) + +const ( + // MaxFileSize skips generated sources too large to be read by hand. + MaxFileSize = 2 << 20 + // scanTimeout bounds a scan; a first scan of a large project takes well + // under a second on a developer machine (Viff ADR 0040). + scanTimeout = 2 * time.Minute + // DefaultFreshness is how long a scan answers queries before the next + // query rescans. + DefaultFreshness = 2 * time.Second + // MaxReferences bounds a reference list. + MaxReferences = 5000 +) + +// Options configure a Workspace. +type Options struct { + // Root is the absolute project folder. + Root string + // State is a writable directory for extracted JDK sources; "" disables + // navigation into the JDK. + State string + // FindJDK locates the JDK whose sources are used; nil disables them. + FindJDK func(context.Context) (jdk.JDK, bool) + // ListFiles lists the files of root as slash-separated relative paths; + // nil uses ListFiles. + ListFiles func(ctx context.Context, root string) ([]string, error) + // Freshness overrides DefaultFreshness; a negative value rescans before + // every query. + Freshness time.Duration + // Logf reports problems that do not fail a query; nil discards them. + Logf func(format string, args ...any) +} + +// Doc names a document: a file of the workspace (Rel set), a JDK 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 +} + +// Covered reports whether navigation answers queries from d. +func (d Doc) Covered() bool { return d.Rel != "" || d.Library != "" } + +// Target is a declaration or a usage: an absolute file path and the 0-based +// line and byte column of the name there. +type Target struct { + File string + Line, Column int + // Name is the identifier at the target, used to size its range. + Name string +} + +// Workspace is the index of one folder. Its methods are safe for concurrent +// use. +type Workspace struct { + opt Options + ix *codenav.Index + + mu sync.Mutex + stamps map[string]stamp // indexed path -> version parsed from disk + overlays map[string][]byte // open documents, by relative path + scannedAt time.Time // when the last applied scan started + ready bool + 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 +} + +type stamp struct { + size int64 + mod time.Time +} + +func (a stamp) same(b stamp) bool { return a.size == b.size && a.mod.Equal(b.mod) } + +// scanRun is a scan that queries wait for. +type scanRun struct { + done chan struct{} + err error +} + +// New returns a workspace over opt.Root; nothing is read until the first +// query. +func New(opt Options) *Workspace { + if opt.ListFiles == nil { + opt.ListFiles = ListFiles + } + if opt.Freshness == 0 { + opt.Freshness = DefaultFreshness + } + if opt.Logf == nil { + opt.Logf = func(string, ...any) {} + } + return &Workspace{opt: opt, ix: codenav.NewIndex(), overlays: map[string][]byte{}} +} + +// Root is the workspace folder. +func (w *Workspace) Root() string { return w.opt.Root } + +// DocOf classifies file, an absolute path. +func (w *Workspace) DocOf(file string) Doc { + if !codenav.Supports(file) { + return Doc{} + } + w.mu.Lock() + s := w.sources + w.mu.Unlock() + if s != nil && w.opt.State != "" { + if name, ok := s.EntryOf(w.opt.State, file); ok { + return Doc{Library: name} + } + } + rel, err := filepath.Rel(w.opt.Root, file) + if err != nil || rel == "." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || rel == ".." || filepath.IsAbs(rel) { + return Doc{} + } + return Doc{Rel: filepath.ToSlash(rel)} +} + +// Open records the editor's text of a workspace document, which the index +// then uses instead of the file on disk. +func (w *Workspace) Open(d Doc, text []byte) { + if d.Rel == "" { + return + } + w.mu.Lock() + w.overlays[d.Rel] = text + w.mu.Unlock() + w.ix.Put(codenav.ParseFile(d.Rel, text)) +} + +// Close drops the editor's text of d; the copy on disk is indexed again. +func (w *Workspace) Close(d Doc) { + if d.Rel == "" { + return + } + w.mu.Lock() + delete(w.overlays, d.Rel) + delete(w.stamps, d.Rel) // the next scan reads the file again + w.mu.Unlock() + full := w.abs(d.Rel) + if fi, err := os.Stat(full); err == nil && fi.Mode().IsRegular() && fi.Size() <= MaxFileSize { + if src, err := os.ReadFile(full); err == nil { + w.ix.Put(codenav.ParseFile(d.Rel, src)) + return + } + } + w.ix.Remove(d.Rel) +} + +// Invalidate makes the next query rescan, for a change reported by the +// editor (a watched file was created, changed or deleted). +func (w *Workspace) Invalidate() { + w.mu.Lock() + w.stale = true + w.mu.Unlock() +} + +// Definition finds the declaration of the identifier at byte offset off of +// text, the editor's content of d. It reports false when there is no +// identifier there or its declaration is neither in the workspace nor in the +// JDK's sources. +func (w *Workspace) Definition(ctx context.Context, d Doc, text []byte, off int) (Target, bool, error) { + if !d.Covered() { + return Target{}, false, nil + } + if err := w.ensure(ctx); err != nil { + return Target{}, false, err + } + loc, ok, err := w.resolve(d, text, off) + if err != nil || !ok { + return Target{}, false, err + } + t, err := w.target(loc) + if err != nil { + return Target{}, false, err + } + return t, true, nil +} + +// References lists the places that refer to the declaration named at byte +// offset off of text, the editor's content of d, in path, line and column +// order; with decl set the declaration itself comes first. Only occurrences +// that navigate back to the declaration count. Usages of a JDK declaration +// are the workspace's; only a local declared in a JDK source has usages +// there. more reports that the list stopped at MaxReferences. +func (w *Workspace) References(ctx context.Context, d Doc, text []byte, off int, decl bool) (out []Target, more bool, err error) { + if !d.Covered() { + return nil, false, nil + } + if err := w.ensure(ctx); err != nil { + return nil, false, err + } + loc, ok, err := w.resolve(d, text, off) + if err != nil || !ok { + return nil, false, err + } + names := w.ix.UsageNames(loc, loc.Name) + if decl { + t, err := w.target(loc) + if err != nil { + return nil, false, err + } + out = append(out, t) + } + var found []codenav.Location + if loc.Local { + // A local is used only in its own file, the one asked about. + if d.Library != "" { + found = w.ix.LibraryUsages(d.Library, text, loc, names) + } else { + found = w.ix.Usages(d.Rel, text, loc, names) + } + } else { + found, err = w.usages(ctx, d, text, loc, names) + if err != nil { + return nil, false, err + } + } + for _, l := range found { + if len(out) >= MaxReferences { + return out, true, nil + } + t, err := w.target(l) + if err != nil { + continue + } + out = append(out, t) + } + return out, false, nil +} + +// resolve resolves the identifier at off of text, the content of d. A miss +// caused by a JDK source that could not be read is that error. +func (w *Workspace) resolve(d Doc, text []byte, off int) (codenav.Location, bool, error) { + var loc codenav.Location + var ok bool + if d.Library != "" { + loc, ok = w.ix.ResolveLibrary(d.Library, text, off) + } else { + loc, ok = w.ix.Resolve(d.Rel, text, off) + } + if ok { + return loc, true, nil + } + w.mu.Lock() + s := w.sources + w.mu.Unlock() + if s != nil { + if err := s.Library().Err(); err != nil { + return loc, false, fmt.Errorf("go to definition: %w", err) + } + } + return loc, false, nil +} + +// target turns a codenav location into a file, extracting a JDK source. +func (w *Workspace) target(l codenav.Location) (Target, error) { + t := Target{Line: l.Line, Column: l.Column, Name: l.Name} + if !l.Library { + t.File = w.abs(l.Path) + return t, nil + } + w.mu.Lock() + s := w.sources + w.mu.Unlock() + if s == nil || w.opt.State == "" { + return Target{}, errors.New("no JDK sources") + } + file, err := s.Extract(w.opt.State, l.Path) + if err != nil { + return Target{}, err + } + t.File = file + return t, nil +} + +func (w *Workspace) abs(rel string) string { + return filepath.Join(w.opt.Root, filepath.FromSlash(rel)) +} + +// Text returns the content of a target's file: the editor's text of an open +// workspace document, otherwise the file on disk. +func (w *Workspace) Text(file string) ([]byte, error) { + if d := w.DocOf(file); d.Rel != "" { + w.mu.Lock() + text, ok := w.overlays[d.Rel] + w.mu.Unlock() + if ok { + return text, nil + } + } + return os.ReadFile(file) +} + +// usages reads the indexed files and collects the usages of loc in path +// order. The asking document stands in for its copy on disk, and open +// documents for theirs. +func (w *Workspace) usages(ctx context.Context, d Doc, text []byte, loc codenav.Location, names []string) ([]codenav.Location, error) { + files := w.ix.Paths() + w.mu.Lock() + overlays := make(map[string][]byte, len(w.overlays)) + for k, v := range w.overlays { + overlays[k] = v + } + w.mu.Unlock() + if d.Rel != "" { + overlays[d.Rel] = text + } + per := make([][]codenav.Location, len(files)) + next := make(chan int) + var wg sync.WaitGroup + for range workers() { + wg.Add(1) + go func() { + defer wg.Done() + for i := range next { + p := files[i] + src, ok := overlays[p] + if !ok { + full := w.abs(p) + fi, err := os.Stat(full) + if err != nil || !fi.Mode().IsRegular() || fi.Size() > MaxFileSize { + continue + } + if src, err = os.ReadFile(full); err != nil { + continue + } + } + per[i] = w.ix.Usages(p, src, loc, names) + } + }() + } + for i := range files { + if ctx.Err() != nil { + break + } + next <- i + } + close(next) + wg.Wait() + if err := ctx.Err(); err != nil { + return nil, fmt.Errorf("find references: %w", err) + } + var out []codenav.Location + for _, ls := range per { + out = append(out, ls...) + } + return out, nil +} + +// ensure brings the index up to date, joining a scan in flight. +func (w *Workspace) ensure(ctx context.Context) error { + w.mu.Lock() + fresh := w.ready && !w.stale && w.opt.Freshness > 0 && time.Since(w.scannedAt) < w.opt.Freshness + if fresh { + w.mu.Unlock() + return nil + } + run := w.scan + if run == nil { + run = &scanRun{done: make(chan struct{})} + w.scan = run + go w.runScan(run) + } + w.mu.Unlock() + select { + case <-run.done: + return run.err + case <-ctx.Done(): + return ctx.Err() + } +} + +// runScan scans the folder in the background and applies the result. It +// is not cancelled by the query that started it, which other queries may +// be waiting on too. +func (w *Workspace) runScan(run *scanRun) { + ctx, cancel := context.WithTimeout(context.Background(), scanTimeout) + defer cancel() + started := time.Now() + w.mu.Lock() + old := w.stamps + w.stale = false + lookJDK := w.opt.FindJDK != nil && (!w.looked || w.sources != nil && w.sources.Changed()) + if lookJDK { + w.looked = true + } + w.mu.Unlock() + + // Listing src.zip takes about as long as a first scan (Viff ADR 0041), + // so the two run side by side. + var found *jdk.Sources + var jdkErr error + listed := make(chan struct{}) + if lookJDK { + go func() { + defer close(listed) + if j, ok := w.opt.FindJDK(ctx); ok { + found, jdkErr = jdk.Open(j) + } + }() + } else { + close(listed) + } + res, err := w.scanFiles(ctx, old) + <-listed + + w.mu.Lock() + if lookJDK { + if jdkErr != nil { + w.opt.Logf("JDK sources unavailable: %v", jdkErr) + } + if found != nil || jdkErr == nil { + w.sources = found + } + 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 err == nil { + for _, p := range res.removed { + if _, open := w.overlays[p]; !open { + w.ix.Remove(p) + } + } + for _, f := range res.parsed { + if _, open := w.overlays[f.Path]; !open { + w.ix.Put(f) + } + } + w.stamps = res.stamps + w.scannedAt = started + w.ready = true + } + run.err = err + w.scan = nil + w.mu.Unlock() + close(run.done) +} + +// scanResult is what changed since the previous scan. +type scanResult struct { + parsed []*codenav.File + removed []string + 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) + } + var paths []string + for _, p := range all { + if codenav.Supports(p) { + paths = append(paths, p) + } + } + sort.Strings(paths) + stamps := make([]stamp, len(paths)) + indexed := make([]bool, len(paths)) + parsed := make([]*codenav.File, len(paths)) + next := make(chan int) + var wg sync.WaitGroup + for range workers() { + wg.Add(1) + go func() { + defer wg.Done() + for i := range next { + p := paths[i] + full := w.abs(p) + fi, err := os.Stat(full) + if err != nil || !fi.Mode().IsRegular() || fi.Size() > MaxFileSize { + continue + } + st := stamp{size: fi.Size(), mod: fi.ModTime()} + if o, ok := old[p]; ok && o.same(st) { + stamps[i], indexed[i] = st, true + continue + } + src, err := os.ReadFile(full) + if err != nil { + continue + } + stamps[i], indexed[i] = st, true + parsed[i] = codenav.ParseFile(p, src) + } + }() + } + for i := range paths { + if ctx.Err() != nil { + break + } + next <- i + } + close(next) + wg.Wait() + if err := ctx.Err(); err != nil { + return scanResult{}, fmt.Errorf("index source files: %w", err) + } + res := scanResult{stamps: make(map[string]stamp, len(paths))} + for i, p := range paths { + if !indexed[i] { + continue + } + res.stamps[p] = stamps[i] + if parsed[i] != nil { + res.parsed = append(res.parsed, parsed[i]) + } + } + for p := range old { + if _, ok := res.stamps[p]; !ok { + res.removed = append(res.removed, p) + } + } + return res, nil +} + +func workers() int { return min(runtime.GOMAXPROCS(0), 8) } diff --git a/internal/workspace/workspace_test.go b/internal/workspace/workspace_test.go new file mode 100644 index 0000000..aa16e3e --- /dev/null +++ b/internal/workspace/workspace_test.go @@ -0,0 +1,284 @@ +package workspace + +import ( + "archive/zip" + "context" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + "github.com/Lithe-IDEA/lithe-plugin-java-navigation/internal/jdk" +) + +// testTimeout bounds every query of these tests. +const testTimeout = 10 * time.Second + +func ctx(t *testing.T) context.Context { + t.Helper() + c, cancel := context.WithTimeout(context.Background(), testTimeout) + t.Cleanup(cancel) + return c +} + +func write(t *testing.T, root string, files map[string]string) { + t.Helper() + for rel, body := range files { + p := filepath.Join(root, filepath.FromSlash(rel)) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + } +} + +// at returns the byte offset of the n-th (0-based) occurrence of needle in +// text, plus skip bytes. +func at(t *testing.T, text, needle string, n, skip int) int { + t.Helper() + off := -1 + for i := 0; i <= n; i++ { + j := strings.Index(text[off+1:], needle) + if j < 0 { + t.Fatalf("occurrence %d of %q not found", n, needle) + } + off += 1 + j + } + return off + skip +} + +var project = map[string]string{ + "src/main/java/com/acme/model/User.java": `package com.acme.model; + +import lombok.Data; + +@Data +public class User { + private String name; + private int age; + + public User(String name) { + this.name = name; + } +} +`, + "src/main/java/com/acme/service/UserService.java": `package com.acme.service; + +import com.acme.model.User; + +public class UserService { + public User create(String name) { + User user = new User(name); + System.out.println(user.getName()); + return user; + } +} +`, + "target/generated/Ignored.java": "package gen; public class User {}\n", +} + +func newTestWorkspace(t *testing.T, root string, findJDK func(context.Context) (jdk.JDK, bool)) *Workspace { + t.Helper() + return New(Options{ + Root: root, State: filepath.Join(t.TempDir(), "state"), FindJDK: findJDK, + ListFiles: walkFiles, Freshness: -1, Logf: t.Logf, + }) +} + +func TestDefinitionAndReferencesAcrossFiles(t *testing.T) { + root := t.TempDir() + write(t, root, project) + w := newTestWorkspace(t, root, nil) + svc := project["src/main/java/com/acme/service/UserService.java"] + doc := w.DocOf(filepath.Join(root, "src/main/java/com/acme/service/UserService.java")) + if doc.Rel == "" { + t.Fatalf("DocOf = %+v", doc) + } + + // The type in "User user = new User(name)" goes to the class. + tgt, ok, err := w.Definition(ctx(t), doc, []byte(svc), at(t, svc, "User user", 0, 1)) + if err != nil || !ok { + t.Fatalf("definition = %v %v", ok, err) + } + if want := filepath.Join(root, "src/main/java/com/acme/model/User.java"); tgt.File != want || tgt.Line != 5 { + t.Errorf("type target = %+v, want %s line 5", tgt, want) + } + // A Lombok getter goes to its field. + tgt, ok, err = w.Definition(ctx(t), doc, []byte(svc), at(t, svc, "getName", 0, 2)) + if err != nil || !ok || tgt.Line != 6 || tgt.Name != "getName" { + t.Errorf("getter target = %+v %v %v, want the name field on line 6", tgt, ok, err) + } + // A name the project and the JDK do not declare yields nothing. + if _, ok, _ := w.Definition(ctx(t), doc, []byte(svc), at(t, svc, "println", 0, 1)); ok { + t.Error("println resolved without a JDK") + } + + // References of the name field: the getter call, the constructor's assignment. + user := project["src/main/java/com/acme/model/User.java"] + udoc := w.DocOf(filepath.Join(root, "src/main/java/com/acme/model/User.java")) + refs, more, err := w.References(ctx(t), udoc, []byte(user), at(t, user, "name;", 0, 1), true) + if err != nil || more { + t.Fatal(err, more) + } + var got []string + for _, r := range refs { + rel, _ := filepath.Rel(root, r.File) + got = append(got, filepath.ToSlash(rel)+":"+strconv.Itoa(r.Line)+":"+r.Name) + } + want := []string{ + "src/main/java/com/acme/model/User.java:6:name", // the declaration + "src/main/java/com/acme/model/User.java:10:name", + "src/main/java/com/acme/service/UserService.java:7:getName", + } + if strings.Join(got, " ") != strings.Join(want, " ") { + t.Errorf("references =\n %v\nwant\n %v", got, want) + } +} + +func TestOpenDocumentsShadowTheDisk(t *testing.T) { + root := t.TempDir() + write(t, root, project) + w := newTestWorkspace(t, root, nil) + userPath := filepath.Join(root, "src/main/java/com/acme/model/User.java") + udoc := w.DocOf(userPath) + svcPath := filepath.Join(root, "src/main/java/com/acme/service/UserService.java") + sdoc := w.DocOf(svcPath) + + // An unsaved method in an open User is found from another file. + edited := strings.Replace(project["src/main/java/com/acme/model/User.java"], " private int age;\n", " private int age;\n public void rename(String n) {}\n", 1) + w.Open(udoc, []byte(edited)) + svc := strings.Replace(project["src/main/java/com/acme/service/UserService.java"], "return user;", "user.rename(name);\n return user;", 1) + tgt, ok, err := w.Definition(ctx(t), sdoc, []byte(svc), at(t, svc, "rename", 0, 1)) + if err != nil || !ok || tgt.File != userPath || tgt.Line != 8 { + t.Fatalf("unsaved declaration = %+v %v %v", tgt, ok, err) + } + if text, err := w.Text(userPath); err != nil || string(text) != edited { + t.Errorf("Text of an open document is not the editor's: %v", err) + } + // Closing drops the unsaved edit. + w.Close(udoc) + if _, ok, _ := w.Definition(ctx(t), sdoc, []byte(svc), at(t, svc, "rename", 0, 1)); ok { + t.Error("a closed document's unsaved declaration is still found") + } +} + +func TestRescanSeesChangesOnDisk(t *testing.T) { + root := t.TempDir() + write(t, root, project) + w := newTestWorkspace(t, root, nil) + svcPath := filepath.Join(root, "src/main/java/com/acme/service/UserService.java") + sdoc := w.DocOf(svcPath) + src := "package com.acme.service;\nclass Use { void f() { new com.acme.extra.Extra(); } }\n" + if _, ok, _ := w.Definition(ctx(t), sdoc, []byte(src), at(t, src, "Extra(", 0, 1)); ok { + t.Fatal("Extra resolved before it exists") + } + write(t, root, map[string]string{"src/main/java/com/acme/extra/Extra.java": "package com.acme.extra;\npublic class Extra {}\n"}) + tgt, ok, err := w.Definition(ctx(t), sdoc, []byte(src), at(t, src, "Extra(", 0, 1)) + if err != nil || !ok || filepath.Base(tgt.File) != "Extra.java" { + t.Errorf("after the file appeared = %+v %v %v", tgt, ok, err) + } +} + +func TestFilesOutsideTheFolderAreNotCovered(t *testing.T) { + root := t.TempDir() + w := newTestWorkspace(t, root, nil) + for _, p := range []string{filepath.Join(filepath.Dir(root), "Other.java"), filepath.Join(root, "notes.txt"), root} { + if d := w.DocOf(p); d.Covered() { + t.Errorf("DocOf(%s) = %+v, want not covered", p, d) + } + } +} + +func TestWalkSkipsBuildOutputAndHiddenDirectories(t *testing.T) { + root := t.TempDir() + write(t, root, map[string]string{ + "src/A.java": "", "target/B.java": "", "build/C.java": "", ".idea/D.java": "", "module/out/E.java": "", "module/src/F.java": "", + }) + files, err := walkFiles(ctx(t), root) + if err != nil { + t.Fatal(err) + } + if got := strings.Join(files, " "); got != "module/src/F.java src/A.java" { + t.Errorf("walk = %s", got) + } +} + +// fakeJDK writes a src.zip and returns a FindJDK that reports it. +func fakeJDK(t *testing.T, files map[string]string) func(context.Context) (jdk.JDK, bool) { + t.Helper() + p := filepath.Join(t.TempDir(), "src.zip") + f, err := os.Create(p) + if err != nil { + t.Fatal(err) + } + zw := zip.NewWriter(f) + for name, body := range files { + fw, err := zw.Create(name) + if err != nil { + t.Fatal(err) + } + fw.Write([]byte(body)) + } + if err := zw.Close(); err != nil { + t.Fatal(err) + } + f.Close() + return func(context.Context) (jdk.JDK, bool) { return jdk.JDK{Version: "17.0.18", Sources: p}, true } +} + +func TestDefinitionIntoTheJDKAndBack(t *testing.T) { + root := t.TempDir() + write(t, root, project) + find := fakeJDK(t, map[string]string{ + "java.base/java/lang/String.java": `package java.lang; + +public final class String implements CharSequence { + public int length() { + int n = 0; + return n; + } +} +`, + "java.base/java/lang/CharSequence.java": "package java.lang;\n\npublic interface CharSequence {\n int length();\n}\n", + "java.base/java/lang/Object.java": "package java.lang;\n\npublic class Object {\n public native int hashCode();\n}\n", + }) + w := newTestWorkspace(t, root, find) + svcPath := filepath.Join(root, "src/main/java/com/acme/service/UserService.java") + sdoc := w.DocOf(svcPath) + svc := project["src/main/java/com/acme/service/UserService.java"] + + // String (implicitly imported from java.lang) opens the extracted copy. + tgt, ok, err := w.Definition(ctx(t), sdoc, []byte(svc), at(t, svc, "String name", 0, 1)) + if err != nil || !ok { + t.Fatalf("String = %v %v", ok, err) + } + if !strings.Contains(filepath.ToSlash(tgt.File), "/jdk-sources/jdk-17.0.18-") || !strings.HasSuffix(filepath.ToSlash(tgt.File), "java.base/java/lang/String.java") || tgt.Line != 2 { + t.Fatalf("String target = %+v", tgt) + } + // The copy is a JDK source, and navigation continues inside it. + ldoc := w.DocOf(tgt.File) + if ldoc.Library != "java.base/java/lang/String.java" { + t.Fatalf("DocOf(copy) = %+v", ldoc) + } + lib, _ := os.ReadFile(tgt.File) + next, ok, err := w.Definition(ctx(t), ldoc, lib, at(t, string(lib), "CharSequence", 0, 1)) + if err != nil || !ok || !strings.HasSuffix(filepath.ToSlash(next.File), "java/lang/CharSequence.java") { + t.Errorf("CharSequence from the copy = %+v %v %v", next, ok, err) + } + // A local in a JDK source has its usages in that file. + refs, _, err := w.References(ctx(t), ldoc, lib, at(t, string(lib), "n = 0", 0, 0), false) + if err != nil || len(refs) != 1 || refs[0].Line != 5 || refs[0].File != tgt.File { + t.Errorf("local references in a JDK source = %+v %v", refs, err) + } + // hashCode() is inherited from java.lang.Object. + src := "package com.acme.service;\nclass H { int f(UserService s) { return s.hashCode(); } }\n" + hdoc := w.DocOf(filepath.Join(root, "src/main/java/com/acme/service/H.java")) + h, ok, err := w.Definition(ctx(t), hdoc, []byte(src), at(t, src, "hashCode", 0, 1)) + if err != nil || !ok || !strings.HasSuffix(filepath.ToSlash(h.File), "java/lang/Object.java") { + t.Errorf("hashCode = %+v %v %v", h, ok, err) + } +} diff --git a/io.github.lithe-idea.java-navigation/plugin.json b/io.github.lithe-idea.java-navigation/plugin.json new file mode 100644 index 0000000..4847a15 --- /dev/null +++ b/io.github.lithe-idea.java-navigation/plugin.json @@ -0,0 +1,55 @@ +{ + "schemaVersion": 0, + "id": "io.github.lithe-idea.java-navigation", + "displayName": "Java Navigation", + "version": "0.0.1", + "pluginAPIVersion": 0, + "requires": { + "languageServers": 0, + "languages": 0 + }, + "vendor": { + "id": "io.github.lithe-idea", + "displayName": "Lithe Community", + "signatureRequirement": "none" + }, + "permissions": [ + "languageServer.launch" + ], + "activationEvents": [ + "onLanguage:java" + ], + "languages": [ + { + "id": "java", + "displayName": "Java", + "fileExtensions": [ + "java" + ], + "projectFileNames": [ + "pom.xml", + "build.gradle", + "build.gradle.kts", + "settings.gradle", + "settings.gradle.kts" + ] + } + ], + "languageServers": [ + { + "languageID": "java", + "languageIdentifier": "java", + "launch": { + "kind": "packaged", + "path": "bin/java-navigation" + }, + "arguments": [ + "--state-dir", + "${stateDir}" + ], + "validationArguments": [ + "--version" + ] + } + ] +} diff --git a/scripts/package.mjs b/scripts/package.mjs new file mode 100644 index 0000000..21c0a9e --- /dev/null +++ b/scripts/package.mjs @@ -0,0 +1,84 @@ +#!/usr/bin/env node +// Builds the language server for every supported platform and packages each +// into dist/java-navigation--.zip, the asset names Lithe +// Store's release tracker recognizes (docs/publishing.md there). +// +// A plugin publishes one archive per platform, and its packaged launch path +// does not vary by architecture (plugin contract v0, "Process start"), so +// each archive carries the one binary for its platform at bin/java-navigation +// (bin/java-navigation.exe on Windows, whose archive's plugin.json names +// that path). The binary is pure Go, cross-compiled without cgo, so every +// archive builds on any machine. +// +// Usage: node scripts/package.mjs [--expect-tag ] [--platform ] + +import { execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { chmodSync, copyFileSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { join, resolve } from "node:path"; + +const PACKAGE_DIR = "io.github.lithe-idea.java-navigation"; +const ASSET_PREFIX = "java-navigation"; +const BINARY = "java-navigation"; +const TARGETS = [ + { platform: "darwin-arm64", goos: "darwin", goarch: "arm64" }, + { platform: "darwin-x64", goos: "darwin", goarch: "amd64" }, + { platform: "windows-x64", goos: "windows", goarch: "amd64" }, + { platform: "windows-arm64", goos: "windows", goarch: "arm64" }, +]; +// Files copied next to plugin.json in every archive. +const INCLUDED = ["LICENSE", "THIRD_PARTY_NOTICES.md"]; + +const manifest = JSON.parse(readFileSync(`${PACKAGE_DIR}/plugin.json`, "utf8")); +const problems = []; +if (manifest.id !== PACKAGE_DIR) problems.push(`plugin.json id ${manifest.id} must equal the directory ${PACKAGE_DIR}`); +if (!/^[0-9]+\.[0-9]+\.[0-9]+$/.test(manifest.version)) problems.push(`version ${manifest.version} is not MAJOR.MINOR.PATCH`); +if (manifest.vendor?.signatureRequirement !== "none") problems.push('vendor.signatureRequirement must be "none"'); +if (manifest.id === "dev.lithe" || manifest.id.startsWith("dev.lithe.")) problems.push("dev.lithe.* is reserved for official plugins"); +const servers = manifest.languageServers ?? []; +if (servers.length !== 1 || servers[0].launch?.kind !== "packaged" || servers[0].launch?.path !== `bin/${BINARY}`) { + problems.push(`plugin.json must declare one packaged language server at bin/${BINARY}`); +} + +const tagFlag = process.argv.indexOf("--expect-tag"); +if (tagFlag >= 0) { + const tag = process.argv[tagFlag + 1]; + if (tag !== `v${manifest.version}`) problems.push(`tag ${tag} does not match plugin.json version ${manifest.version} (expected v${manifest.version})`); +} +const platformFlag = process.argv.indexOf("--platform"); +const targets = platformFlag >= 0 ? TARGETS.filter((t) => t.platform === process.argv[platformFlag + 1]) : TARGETS; +if (targets.length === 0) problems.push(`unknown platform ${process.argv[platformFlag + 1]}`); +if (problems.length > 0) { + for (const problem of problems) console.error(`✗ ${problem}`); + process.exit(1); +} + +rmSync("dist", { recursive: true, force: true }); +mkdirSync("dist", { recursive: true }); +const stagingRoot = resolve(".cache", "package"); +rmSync(stagingRoot, { recursive: true, force: true }); + +for (const target of targets) { + const staging = join(stagingRoot, target.platform); + const pkg = join(staging, PACKAGE_DIR); + mkdirSync(join(pkg, "bin"), { recursive: true }); + const exe = target.goos === "windows" ? `${BINARY}.exe` : BINARY; + execFileSync( + "go", + ["build", "-trimpath", "-ldflags", `-s -w -buildid= -X main.version=${manifest.version}`, "-o", join(pkg, "bin", exe), "./cmd/java-navigation"], + { stdio: "inherit", env: { ...process.env, CGO_ENABLED: "0", GOOS: target.goos, GOARCH: target.goarch } }, + ); + chmodSync(join(pkg, "bin", exe), 0o755); + const platformManifest = structuredClone(manifest); + platformManifest.languageServers[0].launch.path = `bin/${exe}`; + writeFileSync(join(pkg, "plugin.json"), `${JSON.stringify(platformManifest, null, 2)}\n`); + for (const file of INCLUDED) copyFileSync(file, join(pkg, file)); + + const name = `${ASSET_PREFIX}-${manifest.version}-${target.platform}.zip`; + const asset = resolve("dist", name); + // -X drops extra file attributes; -y stores a symbolic link as a link so + // Lithe refuses it instead of the archive silently carrying its target. + execFileSync("zip", ["-qrXy", asset, PACKAGE_DIR, "-x", "*.DS_Store"], { cwd: staging }); + const sha256 = createHash("sha256").update(readFileSync(asset)).digest("hex"); + console.log(`dist/${name} ${sha256}`); +}