diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f401899..0c2faf5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -33,9 +33,9 @@ jobs: - 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 + # Releases on plugin contract v0, which only the new desktop client + # installs, and that client ships as desktop-v* pre-releases. + PRERELEASE_TAGS: v0.0.1 v0.0.2 run: | prerelease=false for tag in $PRERELEASE_TAGS; do @@ -49,7 +49,7 @@ jobs: echo fi if [[ "$prerelease" == true ]]; then - echo "安装:预发布版本,Lithe Store 自动追踪会跳过,暂不收录 Store。下载与你的系统和芯片对应的 ZIP,在 设置 → 插件 中点击页面标题栏右侧的 从文件安装…,选中 ZIP 后核对 SHA-256 并确认。" + echo "安装:预发布版本,Lithe Store 自动追踪会跳过,索引条目手工提交(https://github.com/Lithe-IDEA/Lithe-store);条目合并后可在 设置 → 插件 → 插件市场 中搜索 Java Navigation 并安装。也可以下载与你的系统和芯片对应的 ZIP,在 设置 → 插件 中点击页面标题栏右侧的 从文件安装…,选中 ZIP 后核对 SHA-256 并确认。" else echo "安装:Lithe Store(https://github.com/Lithe-IDEA/Lithe-store)中的 Java Navigation 条目合并后,可在 设置 → 插件 → 插件市场 中搜索 Java Navigation 并安装。" fi diff --git a/README.md b/README.md index d11dde1..b50f8ec 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ | 跳进 JDK | 项目里找不到的名字到本机 JDK 的 `src.zip` 里找,`String`、`List`、`hashCode()` 等都能跳,并能在 JDK 源码里继续跳 | | 跳进依赖库 | Maven 项目的依赖(含传递依赖)从本地仓库读:下载过 `-sources.jar` 的打开源码,否则打开从 `.class` 生成的签名存根(类、成员类、字段、构造器、方法,参数名取自调试信息),并能在里面继续跳;`@Schema(description = ...)`、`log.info(...)`、`StrUtil.isEmpty(...)` 都能跳 | | 老项目的 jar | 没有 Maven 的 Ant、Eclipse、MyEclipse 项目,读 `.classpath` 里列出的 jar(含 `M2_REPO` 变量和 `sourcepath`),以及 `lib/`、`libs/`、`WEB-INF/lib` 里的 jar | +| Eclipse 容器 | `.classpath` 里的服务器运行时容器(Tomcat、Jetty、WildFly 等)和 JUnit 容器换成本机已有的 jar,`HttpServletRequest`、`@WebServlet`、`@Test` 都能跳;没有 pom 的 `WEB-INF/web.xml` Web 项目同样补上 Servlet/JSP API | | 生成代码 | 模块的 `target/generated-sources`(protobuf、注解处理器)和 Gradle 的 `build/generated` 也进索引,即使被 `.gitignore` 忽略 | | 查找引用 | 只列出能跳回这个声明的出现,所以列表和在任一行上跳转的结果一致;`package`/`import` 不算引用 | | 文档结构 | 类型、成员和嵌套类型的大纲(`textDocument/documentSymbol`) | @@ -45,6 +46,13 @@ `WEB-INF/lib`、`WebContent/WEB-INF/lib`、`WebRoot/WEB-INF/lib` 等即使被 `.gitignore` 忽略也会读。源码取 `.classpath` 的 `sourcepath`,或旁边的 `-sources.jar`、`-src.zip`;源码包不是按包名放的(比如整个源码树打的 zip)就不用,改用存根。 这些 jar 只读,不会被修改。 +- Eclipse 容器(`kind="con"`):服务器运行时容器按运行时名字判断包名(Tomcat 10+、Jetty 11+、WildFly 27+、 + GlassFish/Payara 6+、TomEE 9+ 用 `jakarta.servlet`,更早的用 `javax.servlet`),先找本机装的服务器 + (`CATALINA_HOME`/`CATALINA_BASE`、Homebrew、`/opt`、`/usr/share`、Windows 的 `Program Files`、`~/apache-tomcat-*`) + 的 `lib` 里包名对得上的 `servlet-api.jar`、`jsp-api.jar`、`el-api.jar`,没有就从本地 Maven 仓库取包名对得上的最新 + Servlet API 和 JSP API;`JUNIT_CONTAINER/3`、`/4`、`/5` 取本地仓库里对应大版本的最新 JUnit(4 加上 hamcrest-core)。 + 项目自己已经带了 Servlet 或 JUnit 的 jar 时不再补。没有 `pom.xml`/Gradle 构建文件、但有 `WEB-INF/web.xml` 的 + 项目按 Servlet 容器处理(web.xml 用 Jakarta EE 命名空间时取 `jakarta.servlet`)。不联网下载,本机都没有就跳过。 - `pom.xml`、`.classpath` 或上面任一个 jar 变了、或距上次读取超过 5 分钟,下一次扫描重新读取依赖。 - JDK:按 `JAVA_HOME` → 系统默认 JDK(macOS 的 `/usr/libexec/java_home`,其他系统 `PATH` 上的 `java`)→ 常见安装目录 (含 IntelliJ 的 `~/.jdks`、SDKMAN!)的顺序找第一个带 `src.zip` 的 JDK。只预先列出 zip 目录,某个类第一次被查到时才解析。 @@ -96,9 +104,11 @@ | [Apache Ant](https://github.com/apache/ant) | 2000 年起的老项目,`src/main/org` | 99.1% | 0.19 ms / 1.3 ms | 56 ms | 29 MiB | | [Struts 1](https://github.com/apache/struts1) | 已归档的老框架,JSF/JSP | 97.5% | 0.16 ms / 1.8 ms | 44 ms | 22 MiB | | [JForum 2](https://github.com/rafaelsteil/jforum2) | 已归档的 Ant 项目,jar 提交在 `WEB-INF/lib`,没有 pom | 99.3%(读工作区 jar 之前 95.9%) | 0.15 ms / 1.4 ms | 37 ms | 20 MiB | +| [javaee-blog](https://github.com/Lemonreds/javaee-blog) | Eclipse 动态 Web 项目,Servlet + JSP,Tomcat 7 运行时容器,没有 pom | 100%(解析容器之前 91.7%) | 0.08 ms / 0.94 ms | 34 ms | 17 MiB | +| [OpenCms](https://github.com/alkacon/opencms-core) | 2000 年起的 JSP CMS,Gradle | 94.1% | 0.20 ms / 2.0 ms | 0.13 s | 54 MiB | 剩下没跳到的主要是:依赖没在项目里声明或本地仓库没有(JFinal 的 Servlet 依赖在 pom 里被注释掉了,Struts 的 -JSF 依赖没下载过)、数组的 `.length`(JDT LS 也不跳)、`java.util.Date` 这种全限定名里的包名段,以及 lambda 参数。 +JSF 依赖没下载过,OpenCms 的 Gradle 依赖读不了)、数组的 `.length`(JDT LS 也不跳)、`java.util.Date` 这种全限定名里的包名段,以及 lambda 参数。 ## 限制 @@ -110,6 +120,7 @@ JSF 依赖没下载过)、数组的 `.length`(JDT LS 也不跳)、`java.ut 变量和字段声明的类型、能跟下去的调用链),都看不出时取第一个;lambda 参数的类型和泛型替换后的返回类型推不出来, 例如 `list.get(0).getName()` 里的 `getName`、`users.stream().map(u -> u.getName())` 里的 `getName`; 按泛型实参才能区分的重载也会取第一个。 +- **`.jsp` 文件里不能跳转**:插件只处理 `.java` 文件,JSP 里的 `<% %>` 脚本、``、EL 表达式和标签库都不跳。 - **只认一个 JDK**:用的是上面顺序找到的 JDK,不一定是项目编译用的版本。 - **引用不含重写和实现**:查找方法的引用不包括子类重写的方法和通过接口调用到的其他声明。 - **和 Java Support(JDT LS)同时安装**:两个插件都为 Java 声明语言服务器,宿主怎么分配请求还没有定义,暂时不要同时启用。 diff --git a/docs/releases/v0.0.2.md b/docs/releases/v0.0.2.md new file mode 100644 index 0000000..9260067 --- /dev/null +++ b/docs/releases/v0.0.2.md @@ -0,0 +1,19 @@ +0.0.2 让 Eclipse 的老 Web 项目能跳进 Servlet、JSP 和 JUnit。 + +新增: +- 解析 `.classpath` 里的 Eclipse 容器(`kind="con"`):服务器运行时容器(Tomcat、Jetty、WildFly、GlassFish/Payara、TomEE) + 换成本机装的服务器 `lib` 里的 `servlet-api.jar`、`jsp-api.jar`、`el-api.jar`,没装服务器就取本地 Maven 仓库里包名对得上的最新 + Servlet API 和 JSP API;按运行时版本区分 `javax.servlet` 和 `jakarta.servlet`(Tomcat 10+ 等用 `jakarta`)。 + `JUNIT_CONTAINER/3`、`/4`、`/5` 取本地仓库里对应大版本的最新 JUnit。项目自己带了 Servlet 或 JUnit 的 jar 时不再补。 +- 没有 `pom.xml`、Gradle 构建文件,但有 `WEB-INF/web.xml` 的 Web 项目(MyEclipse、NetBeans、直接部署到 Tomcat 的项目)同样补上 Servlet/JSP API。 +- 不联网下载,本机都没有就跳过;这些 jar 只读,不修改。 + +实测:Eclipse 动态 Web 项目 javaee-blog(Servlet + JSP,Tomcat 7 运行时容器,没有 pom)1500 个随机位置能跳转的比例从 91.7% 提到 100%, +`HttpServletRequest`、`getParameter`、`@WebServlet`、`@Test` 都跳到 Servlet API 和 JUnit 的源码;跳转 p50 0.08 ms,内存 17 MiB。 +OpenCms(Gradle)为 94.1%,与 0.0.1 相同。 + +平台:macOS(Apple Silicon、Intel)、Windows(x64、ARM64),每个平台一个 ZIP。 + +权限:`languageServer.launch`。 + +限制:`.jsp` 文件里还不能跳转;Gradle 项目的依赖库还读不了;不要与 Java Support(JDT LS)同时启用。 diff --git a/internal/deps/containers.go b/internal/deps/containers.go new file mode 100644 index 0000000..f68132c --- /dev/null +++ b/internal/deps/containers.go @@ -0,0 +1,249 @@ +package deps + +import ( + "archive/zip" + "os" + "path" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" +) + +// An Eclipse .classpath names some libraries by a container rather than by +// their jars ("kind=con"): the server runtime a web project is deployed to, +// which provides the Servlet and JSP APIs, and JUnit. Eclipse resolves them +// to jars of the developer's machine, so they are not in the tree; without +// them HttpServletRequest, @WebServlet or @Test resolve to nothing in the +// very projects that use them most. The containers are resolved here to jars +// this machine already has: an installed Tomcat's lib directory, else the +// local Maven repository. Nothing is downloaded. +// +// A web application without any build file (a MyEclipse, NetBeans or plain +// Tomcat project with a WEB-INF/web.xml) depends on its server's APIs the +// same way, so it gets them too. + +const ( + javaxServlet = "javax/servlet/" + jakartaServlet = "jakarta/servlet/" +) + +// serverLibDirs lists the lib directories of servers installed on this +// machine, the CATALINA_HOME and CATALINA_BASE ones first. A variable, so +// tests replace it. +var serverLibDirs = func() []string { + var dirs []string + for _, env := range []string{"CATALINA_HOME", "CATALINA_BASE"} { + if v := os.Getenv(env); v != "" { + dirs = append(dirs, filepath.Join(v, "lib")) + } + } + patterns := []string{"/opt/homebrew/opt/tomcat*/libexec/lib", "/usr/local/opt/tomcat*/libexec/lib", + "/usr/share/tomcat*/lib", "/opt/tomcat*/lib", "/Library/Tomcat/lib"} + if pf := os.Getenv("ProgramFiles"); pf != "" { + patterns = append(patterns, filepath.Join(pf, "Apache Software Foundation", "Tomcat *", "lib")) + } + if home, err := os.UserHomeDir(); err == nil { + patterns = append(patterns, filepath.Join(home, "apache-tomcat-*", "lib")) + } + for _, p := range patterns { + found, _ := filepath.Glob(p) + sort.Sort(sort.Reverse(sort.StringSlice(found))) + dirs = append(dirs, found...) + } + return dirs +} + +// runtimeMajor finds the major version in a runtime's name, such as the 10 +// of "Apache Tomcat v10.1". +var runtimeMajor = regexp.MustCompile(`\d+`) + +// serverRuntime reports whether the container path stands for a server +// runtime, and the package its Servlet API lives in. +func serverRuntime(container string) (ns string, ok bool) { + segments := strings.Split(container, "/") + switch { + case segments[0] == "org.eclipse.jst.server.core.container": + name := segments[len(segments)-1] + major, _ := strconv.Atoi(runtimeMajor.FindString(name)) + // The release that moved each server to Jakarta EE 9's packages. + lower := strings.ToLower(name) + for server, first := range map[string]int{"tomcat": 10, "jetty": 11, "wildfly": 27, "glassfish": 6, "payara": 6, "tomee": 9} { + if strings.Contains(lower, server) && major >= first { + return jakartaServlet, true + } + } + return javaxServlet, true + case strings.HasPrefix(container, "org.eclipse.jst.j2ee."): + // The web app libraries (WEB-INF/lib, read anyway) and EAR modules. + return "", false + } + upper := strings.ToUpper(container) + if strings.Contains(upper, "CONTAINER") && (strings.Contains(upper, "J2EE1") || strings.Contains(upper, "JAVAEE_")) { + // MyEclipse's J2EE 1.4 and Java EE 5 to 7 libraries. + return javaxServlet, true + } + return "", false +} + +// container records what a .classpath container stands for; resolveContainers +// adds its jars once the tree's own are listed. +func (t *treeJars) container(container string) { + if ns, ok := serverRuntime(container); ok { + if t.servlet == "" { + t.servlet = ns + } + return + } + if rest, ok := strings.CutPrefix(container, "org.eclipse.jdt.junit.JUNIT_CONTAINER"); ok && t.junit == "" { + t.junit = strings.TrimPrefix(rest, "/") + if t.junit == "" { + t.junit = "4" + } + } +} + +// resolveContainers adds the jars of the recorded containers that the tree +// does not keep itself. +func (t *treeJars) resolveContainers(repo string) { + if t.servlet != "" && !t.keeps(func(name string) bool { + return strings.Contains(name, "servlet-api") || strings.Contains(name, "servlet.api") || + strings.HasPrefix(name, "javax.servlet") || strings.HasPrefix(name, "jakarta.servlet") + }) { + t.serverAPIs(t.servlet, repo) + } + if t.junit != "" && repo != "" && !t.keeps(func(name string) bool { return strings.Contains(name, "junit") }) { + t.addJUnit(t.junit, repo) + } +} + +// keeps reports whether a jar already listed has a lower-case file name +// that matches. +func (t *treeJars) keeps(match func(string) bool) bool { + for _, j := range t.out { + if match(strings.ToLower(filepath.Base(j.Path))) { + return true + } + } + return false +} + +// serverAPIs adds the Servlet and JSP APIs in package ns: from a server +// installed on this machine, else from the local repository. +func (t *treeJars) serverAPIs(ns, repo string) { + for _, dir := range serverLibDirs() { + api := filepath.Join(dir, "servlet-api.jar") + if !jarHas(api, ns+"http/HttpServlet.class") { + continue + } + for _, name := range []string{"servlet-api.jar", "jsp-api.jar", "el-api.jar"} { + t.add(filepath.Join(dir, name), "") + } + return + } + if repo == "" { + return + } + r := &localRepo{root: repo, versions: map[string][]string{}} + servlet := [][2]string{{"jakarta.servlet", "jakarta.servlet-api"}} + jsp := [][2]string{{"jakarta.servlet.jsp", "jakarta.servlet.jsp-api"}} + if ns == javaxServlet { + // The javax artifacts first; jakarta's 4.x releases are a last resort. + servlet = append([][2]string{{"javax.servlet", "javax.servlet-api"}, {"javax.servlet", "servlet-api"}}, servlet...) + jsp = append([][2]string{{"javax.servlet.jsp", "javax.servlet.jsp-api"}, {"javax.servlet.jsp", "jsp-api"}}, jsp...) + } + t.newestHaving(r, servlet, ns+"http/HttpServlet.class") + t.newestHaving(r, jsp, ns+"jsp/JspException.class") +} + +// newestHaving adds the newest jar of the first artifact, in order, that +// holds entry: jakarta.servlet-api 4 still declares javax.servlet, so the +// package is read from the jar rather than assumed from its name. +func (t *treeJars) newestHaving(r *localRepo, artifacts [][2]string, entry string) { + for _, a := range artifacts { + versions := r.list(a[0], a[1]) + for i := len(versions) - 1; i >= 0; i-- { + if t.addRepoJar(r, a[0], a[1], versions[i], entry) { + return + } + } + } +} + +// addJUnit adds the JUnit of a JUNIT_CONTAINER/ entry from the +// local repository. +func (t *treeJars) addJUnit(version, repo string) { + r := &localRepo{root: repo, versions: map[string][]string{}} + switch version { + case "5": + t.addNewest(r, "org.junit.jupiter", "junit-jupiter-api", "5.") + case "3": + t.addNewest(r, "junit", "junit", "3.") + default: + t.addNewest(r, "junit", "junit", "4.") + t.addNewest(r, "org.hamcrest", "hamcrest-core", "") + } +} + +// addNewest adds the newest version of group:artifact that starts with +// prefix. +func (t *treeJars) addNewest(r *localRepo, group, artifact, prefix string) { + versions := r.list(group, artifact) + for i := len(versions) - 1; i >= 0; i-- { + if strings.HasPrefix(versions[i], prefix) && t.addRepoJar(r, group, artifact, versions[i], "") { + return + } + } +} + +// addRepoJar adds group:artifact:version's jar, with its sources when they +// were downloaded, when it exists and holds entry (any jar when entry is ""). +func (t *treeJars) addRepoJar(r *localRepo, group, artifact, version, entry string) bool { + jar, ok := r.jar(group, artifact, version, "") + if !ok || entry != "" && !jarHas(jar, entry) { + return false + } + sources, _ := r.file(group, artifact, version, "sources", "jar") + return t.addAs(jar, sources, group+":"+artifact+":"+version) +} + +// jarHas reports whether the jar at p has the entry name. +func jarHas(p, name string) bool { + z, err := zip.OpenReader(p) + if err != nil { + return false + } + defer z.Close() + for _, f := range z.File { + if f.Name == name { + return true + } + } + return false +} + +// webAppWithoutBuild returns the Servlet package a web application of the +// tree uses when the tree has a WEB-INF/web.xml and no build file to declare +// its server's APIs: Jakarta EE's when the web.xml names its schema. +func webAppWithoutBuild(root string, files []string) (string, bool) { + webXML := "" + for _, f := range files { + switch path.Base(f) { + case "pom.xml", "build.gradle", "build.gradle.kts", "settings.gradle", "settings.gradle.kts": + return "", false + case "web.xml": + if webXML == "" && path.Base(path.Dir(f)) == "WEB-INF" { + webXML = f + } + } + } + if webXML == "" { + return "", false + } + b, err := readLimited(filepath.Join(root, filepath.FromSlash(webXML)), maxPOMSize) + if err == nil && strings.Contains(string(b), "jakarta.ee/xml/ns/jakartaee") { + return jakartaServlet, true + } + return javaxServlet, true +} diff --git a/internal/deps/containers_test.go b/internal/deps/containers_test.go new file mode 100644 index 0000000..9955012 --- /dev/null +++ b/internal/deps/containers_test.go @@ -0,0 +1,157 @@ +package deps + +import ( + "path/filepath" + "strings" + "testing" +) + +// repoJar writes group:artifact:version's jar holding entries into a local +// repository, with a POM so the version is listed. +func repoJar(t *testing.T, repo, group, artifact, version string, entries ...string) { + t.Helper() + dir := filepath.Join(repo, filepath.FromSlash(strings.ReplaceAll(group, ".", "/")), artifact, version) + files := map[string]string{"META-INF/MANIFEST.MF": "Manifest-Version: 1.0\n"} + for _, e := range entries { + files[e] = "" + } + writeFile(t, filepath.Join(dir, artifact+"-"+version+".pom"), "") + writeJar(t, filepath.Join(dir, artifact+"-"+version+".jar"), files) +} + +// servletRepo is a local repository with Servlet, JSP and JUnit releases of +// both packages, as a developer's machine accumulates them. +func servletRepo(t *testing.T) string { + t.Helper() + repo := t.TempDir() + repoJar(t, repo, "javax.servlet", "javax.servlet-api", "3.1.0", "javax/servlet/http/HttpServlet.class") + repoJar(t, repo, "javax.servlet", "javax.servlet-api", "4.0.1", "javax/servlet/http/HttpServlet.class") + repoJar(t, repo, "jakarta.servlet", "jakarta.servlet-api", "4.0.4", "javax/servlet/http/HttpServlet.class") + repoJar(t, repo, "jakarta.servlet", "jakarta.servlet-api", "6.0.0", "jakarta/servlet/http/HttpServlet.class") + repoJar(t, repo, "javax.servlet.jsp", "jsp-api", "2.1", "javax/servlet/jsp/JspException.class") + repoJar(t, repo, "jakarta.servlet.jsp", "jakarta.servlet.jsp-api", "3.1.0", "jakarta/servlet/jsp/JspException.class") + repoJar(t, repo, "junit", "junit", "3.8.1", "junit/framework/TestCase.class") + repoJar(t, repo, "junit", "junit", "4.12", "org/junit/Test.class") + repoJar(t, repo, "junit", "junit", "4.13.2", "org/junit/Test.class") + repoJar(t, repo, "org.hamcrest", "hamcrest-core", "1.3", "org/hamcrest/Matcher.class") + return repo +} + +func noServers(t *testing.T) { + t.Helper() + saved := serverLibDirs + serverLibDirs = func() []string { return nil } + t.Cleanup(func() { serverLibDirs = saved }) +} + +const dynamicWebClasspath = ` + + + + + + + + +` + +// An Eclipse dynamic web project gets its server runtime's Servlet and JSP +// APIs and its JUnit container from the local repository, after the jars it +// keeps; a Tomcat 10 project gets Jakarta EE's packages. +func TestEclipseContainersResolveToLocalJars(t *testing.T) { + noServers(t) + repo := servletRepo(t) + for _, c := range []struct{ runtime, want string }{ + {"v7.0", "commons-io-2.5 javax.servlet:javax.servlet-api:4.0.1 javax.servlet.jsp:jsp-api:2.1 junit:junit:4.13.2 org.hamcrest:hamcrest-core:1.3"}, + {"v10.1", "commons-io-2.5 jakarta.servlet:jakarta.servlet-api:6.0.0 jakarta.servlet.jsp:jakarta.servlet.jsp-api:3.1.0 junit:junit:4.13.2 org.hamcrest:hamcrest-core:1.3"}, + } { + root := t.TempDir() + files := []string{treeJar(t, root, "WebContent/WEB-INF/lib/commons-io-2.5.jar"), ".classpath", "WebContent/WEB-INF/web.xml"} + writeFile(t, filepath.Join(root, ".classpath"), strings.Replace(dynamicWebClasspath, "%s", c.runtime, 1)) + writeFile(t, filepath.Join(root, "WebContent/WEB-INF/web.xml"), "") + if got := coords(TreeJars(root, files, repo)); got != c.want { + t.Errorf("Tomcat %s:\n got %s\n want %s", c.runtime, got, c.want) + } + } +} + +// A Servlet API the tree keeps itself wins over the repository's, and so +// does a JUnit jar in its lib directory. +func TestContainersDoNotDuplicateTheTreesJars(t *testing.T) { + noServers(t) + repo := servletRepo(t) + root := t.TempDir() + files := []string{ + treeJar(t, root, "WebContent/WEB-INF/lib/commons-io-2.5.jar"), + treeJar(t, root, "lib/servlet-api.jar"), + treeJar(t, root, "lib/junit-4.8.jar"), + ".classpath", + } + writeFile(t, filepath.Join(root, ".classpath"), strings.Replace(dynamicWebClasspath, "%s", "v7.0", 1)) + if got, want := coords(TreeJars(root, files, repo)), "commons-io-2.5 servlet-api junit-4.8"; got != want { + t.Errorf("got %s, want %s", got, want) + } +} + +// A Tomcat installed on the machine provides the APIs before the +// repository does, when its package matches the runtime's. +func TestAnInstalledServerProvidesTheAPIs(t *testing.T) { + repo := servletRepo(t) + tomcat9, tomcat10 := t.TempDir(), t.TempDir() + writeJar(t, filepath.Join(tomcat9, "servlet-api.jar"), map[string]string{"javax/servlet/http/HttpServlet.class": ""}) + writeJar(t, filepath.Join(tomcat9, "jsp-api.jar"), map[string]string{"javax/servlet/jsp/JspException.class": ""}) + writeJar(t, filepath.Join(tomcat10, "servlet-api.jar"), map[string]string{"jakarta/servlet/http/HttpServlet.class": ""}) + saved := serverLibDirs + serverLibDirs = func() []string { return []string{tomcat10, tomcat9} } + t.Cleanup(func() { serverLibDirs = saved }) + + root := t.TempDir() + writeFile(t, filepath.Join(root, ".classpath"), strings.Replace(dynamicWebClasspath, "%s", "v9.0", 1)) + got := TreeJars(root, []string{".classpath"}, repo) + var paths []string + for _, j := range got { + paths = append(paths, j.Path) + } + want := []string{filepath.Join(tomcat9, "servlet-api.jar"), filepath.Join(tomcat9, "jsp-api.jar")} + if len(paths) < 2 || paths[0] != want[0] || paths[1] != want[1] { + t.Errorf("jars = %v, want the Tomcat 9 APIs %v first", paths, want) + } +} + +// A web application with no build file and no .classpath (MyEclipse, +// NetBeans or a plain Tomcat project) gets the APIs its web.xml implies; one +// with a pom.xml declares them itself. +func TestAWebAppWithoutABuildFileGetsTheServletAPI(t *testing.T) { + noServers(t) + repo := servletRepo(t) + root := t.TempDir() + writeFile(t, filepath.Join(root, "WebRoot/WEB-INF/web.xml"), ``) + files := []string{"WebRoot/WEB-INF/web.xml", "src/a/A.java"} + if got, want := coords(TreeJars(root, files, repo)), "jakarta.servlet:jakarta.servlet-api:6.0.0 jakarta.servlet.jsp:jakarta.servlet.jsp-api:3.1.0"; got != want { + t.Errorf("got %s, want %s", got, want) + } + if got := TreeJars(root, append(files, "pom.xml"), repo); len(got) != 0 { + t.Errorf("a Maven web app got %s", coords(got)) + } +} + +func TestServerRuntimeContainers(t *testing.T) { + for _, c := range []struct { + container, ns string + ok bool + }{ + {"org.eclipse.jst.server.core.container/org.eclipse.jst.server.tomcat.runtimeTarget/Apache Tomcat v8.5", javaxServlet, true}, + {"org.eclipse.jst.server.core.container/org.eclipse.jst.server.tomcat.runtimeTarget/Apache Tomcat v10.0", jakartaServlet, true}, + {"org.eclipse.jst.server.core.container/org.eclipse.jst.server.generic.runtimeTarget/WildFly 26", javaxServlet, true}, + {"org.eclipse.jst.server.core.container/org.eclipse.jst.server.generic.runtimeTarget/WildFly 30", jakartaServlet, true}, + {"com.genuitec.eclipse.j2eedt.core.J2EE14_CONTAINER", javaxServlet, true}, + {"melibrary.com.genuitec.eclipse.j2eedt.core.MYECLIPSE_JAVAEE_5_CONTAINER", javaxServlet, true}, + {"org.eclipse.jst.j2ee.internal.web.container", "", false}, + {"org.eclipse.jdt.launching.JRE_CONTAINER", "", false}, + {"org.eclipse.jdt.junit.JUNIT_CONTAINER/4", "", false}, + } { + if ns, ok := serverRuntime(c.container); ns != c.ns || ok != c.ok { + t.Errorf("%s = %q %v, want %q %v", c.container, ns, ok, c.ns, c.ok) + } + } +} diff --git a/internal/deps/tree.go b/internal/deps/tree.go index a9ce114..d12952f 100644 --- a/internal/deps/tree.go +++ b/internal/deps/tree.go @@ -42,6 +42,8 @@ var projectMarkers = map[string]bool{"build.xml": true, ".classpath": true, ".pr // slash-separated relative paths; repo resolves Eclipse's M2_REPO variable // and may be "". Jars that do not exist are left out. A jar's sources are the // .classpath entry's sourcepath, else a -sources.jar or -src.zip next to it. +// Last come the jars of the .classpath's server runtime and JUnit containers +// (see container), when the tree does not keep them itself. func TreeJars(root string, files []string, repo string) []Jar { t := treeJars{seen: map[string]bool{}} var markerDirs []string @@ -71,22 +73,36 @@ func TreeJars(root string, files []string, repo string) []Jar { } } } + if t.servlet == "" { + t.servlet, _ = webAppWithoutBuild(root, files) + } + t.resolveContainers(repo) return t.out } type treeJars struct { out []Jar seen map[string]bool + // servlet is the package of the Servlet API a server runtime provides, + // "" when the tree uses none; junit is the JUnit container's version, + // "" when there is none. resolveContainers adds them last. + servlet string + junit string } // add lists jar once, with sources when given or found next to it. func (t *treeJars) add(jar, sources string) { + t.addAs(jar, sources, strings.TrimSuffix(filepath.Base(jar), ".jar")) +} + +// addAs is add with the jar's coordinates, and reports whether it listed it. +func (t *treeJars) addAs(jar, sources, coord string) bool { jar = filepath.Clean(jar) if len(t.out) >= maxTreeJars || t.seen[jar] || !isLibraryJar(filepath.Base(jar)) { - return + return false } if fi, err := os.Stat(jar); err != nil || !fi.Mode().IsRegular() { - return + return false } t.seen[jar] = true if sources == "" { @@ -94,7 +110,8 @@ func (t *treeJars) add(jar, sources string) { } else if fi, err := os.Stat(sources); err != nil || !fi.Mode().IsRegular() { sources = "" } - t.out = append(t.out, Jar{Coord: strings.TrimSuffix(filepath.Base(jar), ".jar"), Path: jar, Sources: sources}) + t.out = append(t.out, Jar{Coord: coord, Path: jar, Sources: sources}) + return true } // walkLib adds the jars under dir, two directory levels deep. @@ -161,6 +178,10 @@ func (t *treeJars) classpath(root, rel, repo string) { return "" } for _, e := range cp.Entries { + if e.Kind == "con" { + t.container(e.Path) + continue + } if jar := resolve(e.Kind, e.Path); jar != "" { t.add(jar, resolve(e.Kind, e.SourcePath)) } diff --git a/io.github.lithe-idea.java-navigation/plugin.json b/io.github.lithe-idea.java-navigation/plugin.json index 4847a15..487d05e 100644 --- a/io.github.lithe-idea.java-navigation/plugin.json +++ b/io.github.lithe-idea.java-navigation/plugin.json @@ -2,7 +2,7 @@ "schemaVersion": 0, "id": "io.github.lithe-idea.java-navigation", "displayName": "Java Navigation", - "version": "0.0.1", + "version": "0.0.2", "pluginAPIVersion": 0, "requires": { "languageServers": 0,