fix(java): Detect Lombok inherited from outside the project (#58)

Root cause: `uses_lombok` only scanned build files ~3 levels below the jdtls root, so Lombok inherited from outside that tree was judged `Unused` and no `-javaagent` was attached — even when Lombok was installed in Settings.

Covered: parent pom only in the local Maven repository (settings.xml localRepository honoured, multi-hop, cycle-safe), parent via relativePath outside the workspace, outer Gradle root build / version catalog, `lombok.config` in ancestor directories, and a bounded source fallback (`import lombok.` / `@lombok.`) for convention plugins and BOMs. A Lombok jar installed in Settings is now always attached (like IDEA's always-on Lombok support). Detection in `java_resolve_launch` runs in `spawn_blocking`.

Fixes #33

https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn
Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-10-27 11:19:45 +08:00
1 джерело 55f42d2771
коміт cac95273d3
6 files changed
+316 -30

No files matched your search

Створено
+1
Переглянути файл
@@ -4274,6 +4274,7 @@ dependencies = [
"ide-language",
"ide-run",
"ide-vfs",
"ignore",
"serde",
"serde_json",
"specta",
+1
Переглянути файл
@@ -7,6 +7,7 @@ license.workspace = true
[dependencies]
dunce.workspace = true
ignore.workspace = true
ide-core.workspace = true
ide-language.workspace = true
ide-run.workspace = true
+303 -21
Переглянути файл
@@ -64,7 +64,8 @@ pub fn agent_in_args<'a>(args: impl IntoIterator<Item = &'a str>) -> Option<Path
}
/// The Lombok agent for the project at `root`: the user's own (`own`, from the JVM
/// options) wins, then `managed` (the IDE-installed jar), then the build caches
/// options) wins, then `managed` (the IDE-installed jar, always attached: it is inert
/// without Lombok on the classpath), then, for a project using Lombok, the build caches
/// (`maven_repo`, the configured Maven local repository, else the default one).
pub fn lombok_agent(
root: &Path,
@@ -80,15 +81,16 @@ pub fn lombok_agent(
.map(str::to_owned);
return LombokAgent::Found { jar, version };
}
if !uses_lombok(root) {
return LombokAgent::Unused;
}
if let Some(jar) = managed {
return LombokAgent::Found { jar, version: None };
}
let home = home_dir();
let maven_repo = maven_repo.or_else(|| maven_repository(home.as_deref()));
if !uses_lombok(root, maven_repo.as_deref()) {
return LombokAgent::Unused;
}
let mut dirs = Vec::new();
if let Some(repo) = maven_repo.or_else(|| maven_repository(home.as_deref())) {
if let Some(repo) = maven_repo {
dirs.push(repo.join("org").join("projectlombok").join("lombok"));
}
if let Some(gradle) =
@@ -102,11 +104,31 @@ pub fn lombok_agent(
}
}
/// Build files under `root` (a few levels, for multi-module builds) mentioning Lombok.
pub fn uses_lombok(root: &Path) -> bool {
const FILES: [&str; 4] = ["pom.xml", "build.gradle", "build.gradle.kts", "libs.versions.toml"];
const SKIP: [&str; 7] = ["target", "build", "node_modules", "out", "bin", "src", ".gradle"];
fn walk(dir: &Path, depth: usize) -> bool {
/// Whether the project at `root` uses Lombok. It often arrives through inheritance the
/// project's own build files don't show (a parent POM up the tree or only in the local
/// repository `maven_repo`, an enclosing Gradle build, a convention plugin), so besides
/// those this follows Maven `<parent>` chains, checks an enclosing Gradle build and, last,
/// looks for Java sources importing Lombok, a signal independent of the build.
pub fn uses_lombok(root: &Path, maven_repo: Option<&Path>) -> bool {
let mut poms = Vec::new();
build_files_mention_lombok(root, &mut poms)
|| root.ancestors().skip(1).any(|d| d.join("lombok.config").is_file())
|| parents_mention_lombok(&poms, maven_repo)
|| enclosing_gradle_build_mentions_lombok(root)
|| sources_import_lombok(root)
}
const BUILD_FILES: [&str; 4] = ["pom.xml", "build.gradle", "build.gradle.kts", "libs.versions.toml"];
const OUTPUT_DIRS: [&str; 6] = ["target", "build", "node_modules", "out", "bin", ".gradle"];
fn mentions_lombok(file: &Path) -> bool {
std::fs::read_to_string(file).is_ok_and(|t| t.contains("lombok"))
}
/// Build files under `root` (a few levels, for multi-module builds) mentioning Lombok;
/// the POMs seen go to `poms`.
fn build_files_mention_lombok(root: &Path, poms: &mut Vec<PathBuf>) -> bool {
fn walk(dir: &Path, depth: usize, poms: &mut Vec<PathBuf>) -> bool {
if dir.join("lombok.config").is_file() {
return true;
}
@@ -117,19 +139,133 @@ pub fn uses_lombok(root: &Path) -> bool {
let name = name.to_string_lossy();
let Ok(ty) = e.file_type() else { continue };
if ty.is_dir() {
if depth > 0 && !name.starts_with('.') && !SKIP.contains(&name.as_ref()) {
if depth > 0 && !name.starts_with('.') && name != "src" && !OUTPUT_DIRS.contains(&name.as_ref()) {
subdirs.push(e.path());
}
} else if FILES.contains(&name.as_ref())
&& std::fs::read_to_string(e.path()).is_ok_and(|t| t.contains("lombok"))
{
return true;
} else if BUILD_FILES.contains(&name.as_ref()) {
if mentions_lombok(&e.path()) {
return true;
}
if name == "pom.xml" {
poms.push(e.path());
}
}
}
subdirs.iter().any(|d| walk(d, depth - 1))
subdirs.iter().any(|d| walk(d, depth - 1, poms))
}
// `gradle/libs.versions.toml` sits one level down, modules usually no deeper than three.
walk(root, 3)
walk(root, 3, poms)
}
/// Maven inherits dependencies along `<parent>`: whether an ancestor POM of `poms`
/// mentions Lombok.
fn parents_mention_lombok(poms: &[PathBuf], maven_repo: Option<&Path>) -> bool {
const MAX_HOPS: usize = 8;
let mut seen = std::collections::HashSet::new();
for pom in poms {
let Ok(mut text) = std::fs::read_to_string(pom) else { continue };
let mut current = pom.clone();
for _ in 0..MAX_HOPS {
let Some(parent) = parent_pom(&current, &text, maven_repo) else { break };
if !seen.insert(parent.clone()) {
break;
}
let Ok(parent_text) = std::fs::read_to_string(&parent) else { break };
if parent_text.contains("lombok") {
return true;
}
(current, text) = (parent, parent_text);
}
}
false
}
/// The parent of `pom` (content `text`) as Maven resolves it: the POM at `<relativePath>`
/// (default `../pom.xml`, none when empty) if it is that parent, else
/// `<groupId>/<artifactId>/<version>/<artifactId>-<version>.pom` in the local repository.
fn parent_pom(pom: &Path, text: &str, maven_repo: Option<&Path>) -> Option<PathBuf> {
let text = strip_xml_comments(text);
let (block, _) = split_parent(&text)?;
let coord = |tag| crate::java::xml_value(block, tag).filter(|v| !v.is_empty());
let (group, artifact) = (coord("groupId")?, coord("artifactId")?);
let relative = if block.contains("<relativePath/>") || block.contains("<relativePath />") {
String::new()
} else {
crate::java::xml_value(block, "relativePath").unwrap_or_else(|| "../pom.xml".to_owned())
};
if !relative.is_empty() {
let mut local = pom.parent()?.join(relative.replace('\\', "/"));
if local.is_dir() {
local = local.join("pom.xml");
}
let is_parent = std::fs::read_to_string(&local)
.is_ok_and(|t| own_artifact_id(&strip_xml_comments(&t)).as_deref() == Some(artifact.as_str()));
if is_parent {
return Some(dunce::canonicalize(&local).unwrap_or(local));
}
}
let version = coord("version").filter(|v| !v.contains("${"))?;
let dir = group.split('.').fold(maven_repo?.to_path_buf(), |d, part| d.join(part));
let file = dir.join(&artifact).join(&version).join(format!("{artifact}-{version}.pom"));
file.is_file().then_some(file)
}
/// A POM's `<parent>…</parent>` element and the text without it.
fn split_parent(text: &str) -> Option<(&str, String)> {
let start = text.find("<parent>")?;
let end = start + text[start..].find("</parent>")? + "</parent>".len();
Some((&text[start..end], format!("{}{}", &text[..start], &text[end..])))
}
/// A POM's own `<artifactId>`, not its parent's.
fn own_artifact_id(text: &str) -> Option<String> {
match split_parent(text) {
Some((_, rest)) => crate::java::xml_value(&rest, "artifactId"),
None => crate::java::xml_value(text, "artifactId"),
}
}
/// A Gradle subproject opened on its own: the enclosing build (the nearest ancestor with
/// `settings.gradle[.kts]`) may apply Lombok from its root build file or version catalog.
fn enclosing_gradle_build_mentions_lombok(root: &Path) -> bool {
const MAX_LEVELS: usize = 4;
let settings = |d: &Path| d.join("settings.gradle").is_file() || d.join("settings.gradle.kts").is_file();
root.ancestors().skip(1).take(MAX_LEVELS).find(|d| settings(d)).is_some_and(|build| {
["build.gradle", "build.gradle.kts", "gradle/libs.versions.toml"]
.iter()
.any(|f| mentions_lombok(&build.join(f)))
})
}
/// Java sources under `root` importing Lombok. Bounded (the heads of the first files),
/// honouring ignore files and skipping build output.
fn sources_import_lombok(root: &Path) -> bool {
use std::io::Read;
const MAX_ENTRIES: usize = 50_000;
const MAX_FILES: usize = 2000;
// Imports precede the first type, after at most a license header.
const HEAD: u64 = 8 * 1024;
let mut walker = ignore::WalkBuilder::new(root);
walker.require_git(false).follow_links(false).filter_entry(|e| {
e.depth() == 0
|| !e.file_type().is_some_and(|t| t.is_dir())
|| !OUTPUT_DIRS.contains(&e.file_name().to_string_lossy().as_ref())
});
let imports_lombok = |path: &Path| {
let mut head = Vec::new();
let read = std::fs::File::open(path).and_then(|f| f.take(HEAD).read_to_end(&mut head));
let head = String::from_utf8_lossy(&head);
read.is_ok()
&& (head.contains("@lombok.")
|| head.lines().any(|l| l.trim_start().starts_with("import ") && l.contains(" lombok.")))
};
walker
.build()
.take(MAX_ENTRIES)
.flatten()
.filter(|e| e.file_type().is_some_and(|t| t.is_file()) && e.path().extension().is_some_and(|x| x == "java"))
.take(MAX_FILES)
.any(|e| imports_lombok(e.path()))
}
/// `<localRepository>` of `~/.m2/settings.xml`, else `~/.m2/repository`.
@@ -203,18 +339,164 @@ mod tests {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
fs::write(root.join("pom.xml"), "<project><modules><module>api</module></modules></project>").unwrap();
assert!(!uses_lombok(root));
assert!(!uses_lombok(root, None));
fs::create_dir_all(root.join("api/src/main")).unwrap();
fs::write(root.join("api/src/main/pom.xml"), "<artifactId>lombok</artifactId>").unwrap();
assert!(!uses_lombok(root), "sources are not scanned");
assert!(!uses_lombok(root, None), "build files under src are not scanned");
fs::write(root.join("api/pom.xml"), "<artifactId>lombok</artifactId>").unwrap();
assert!(uses_lombok(root));
assert!(uses_lombok(root, None));
let gradle = tempfile::tempdir().unwrap();
fs::create_dir_all(gradle.path().join("gradle")).unwrap();
fs::write(gradle.path().join("gradle/libs.versions.toml"), "lombok = \"org.projectlombok:lombok:1.18.30\"")
.unwrap();
assert!(uses_lombok(gradle.path()));
assert!(uses_lombok(gradle.path(), None));
}
const LOMBOK_DEP: &str = "<dependencies><dependency><groupId>org.projectlombok</groupId><artifactId>lombok</artifactId></dependency></dependencies>";
fn pom(own: &str, parent: &str) -> String {
format!("<project><modelVersion>4.0.0</modelVersion>{parent}<artifactId>{own}</artifactId></project>")
}
fn parent(artifact: &str, extra: &str) -> String {
format!(
"<parent><groupId>com.acme</groupId><artifactId>{artifact}</artifactId><version>1.0</version>{extra}</parent>"
)
}
#[test]
fn parent_pom_only_in_the_local_repository() {
// #33: the module's parent was `mvn install`ed, its directory isn't on disk.
let tmp = tempfile::tempdir().unwrap();
let (module, repo) = (tmp.path().join("module-a"), tmp.path().join("m2"));
fs::create_dir_all(&module).unwrap();
fs::write(module.join("pom.xml"), pom("module-a", &parent("acme-parent", ""))).unwrap();
assert!(!uses_lombok(&module, Some(&repo)));
let dir = repo.join("com/acme/acme-parent/1.0");
fs::create_dir_all(&dir).unwrap();
// The dependency comes from the grandparent.
fs::write(dir.join("acme-parent-1.0.pom"), pom("acme-parent", &parent("acme-root", ""))).unwrap();
assert!(!uses_lombok(&module, Some(&repo)));
let root = repo.join("com/acme/acme-root/1.0");
fs::create_dir_all(&root).unwrap();
fs::write(
root.join("acme-root-1.0.pom"),
format!("<project><artifactId>acme-root</artifactId>{LOMBOK_DEP}</project>"),
)
.unwrap();
assert!(uses_lombok(&module, Some(&repo)));
assert!(!uses_lombok(&module, None), "no repository, no parent");
let agent = lombok_agent(&module, None, None, Some(repo.clone()));
assert_ne!(agent, LombokAgent::Unused, "missing, or a jar from the Gradle cache");
}
#[test]
fn parent_pom_via_relative_path_outside_the_root() {
let tmp = tempfile::tempdir().unwrap();
let project = tmp.path().join("project");
let module = project.join("modules/api");
fs::create_dir_all(&module).unwrap();
fs::create_dir_all(tmp.path().join("platform")).unwrap();
fs::write(
module.join("pom.xml"),
pom("api", &parent("platform", "<relativePath>../../../platform</relativePath>")),
)
.unwrap();
fs::write(
tmp.path().join("platform/pom.xml"),
format!("<project><artifactId>platform</artifactId>{LOMBOK_DEP}</project>"),
)
.unwrap();
assert!(uses_lombok(&module, None));
assert!(uses_lombok(&project, None), "modules' parents are followed too");
// The default `../pom.xml`, but it is another project's POM: not the parent.
let other = tempfile::tempdir().unwrap();
let child = other.path().join("child");
fs::create_dir_all(&child).unwrap();
fs::write(child.join("pom.xml"), pom("child", &parent("acme-parent", ""))).unwrap();
fs::write(
other.path().join("pom.xml"),
format!("<project><artifactId>unrelated</artifactId>{LOMBOK_DEP}</project>"),
)
.unwrap();
assert!(!uses_lombok(&child, None));
fs::write(
other.path().join("pom.xml"),
format!("<project><artifactId>acme-parent</artifactId>{LOMBOK_DEP}</project>"),
)
.unwrap();
assert!(uses_lombok(&child, None));
// An empty relativePath means: from the repository only.
fs::write(child.join("pom.xml"), pom("child", &parent("acme-parent", "<relativePath/>"))).unwrap();
assert!(!uses_lombok(&child, None));
}
#[test]
fn parent_cycles_terminate() {
let tmp = tempfile::tempdir().unwrap();
let a = tmp.path().join("a");
fs::create_dir_all(&a).unwrap();
fs::write(a.join("pom.xml"), pom("a", &parent("root", ""))).unwrap();
fs::write(tmp.path().join("pom.xml"), pom("root", &parent("root", "<relativePath>pom.xml</relativePath>")))
.unwrap();
assert!(!uses_lombok(&a, None));
}
#[test]
fn enclosing_gradle_build() {
let tmp = tempfile::tempdir().unwrap();
let sub = tmp.path().join("services/billing");
fs::create_dir_all(&sub).unwrap();
fs::write(sub.join("build.gradle.kts"), "plugins { java }").unwrap();
fs::write(tmp.path().join("settings.gradle.kts"), "include(\"services:billing\")").unwrap();
assert!(!uses_lombok(&sub, None));
fs::write(
tmp.path().join("build.gradle.kts"),
"subprojects { dependencies { \"compileOnly\"(\"org.projectlombok:lombok:1.18.36\") } }",
)
.unwrap();
assert!(uses_lombok(&sub, None));
}
#[test]
fn sources_importing_lombok() {
// E.g. a Gradle convention plugin in buildSrc/src or an included build adds it.
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let pkg = root.join("app/src/main/java/com/acme");
fs::create_dir_all(&pkg).unwrap();
fs::write(root.join("build.gradle"), "plugins { id 'acme.java-conventions' }").unwrap();
fs::write(pkg.join("Plain.java"), "package com.acme;\n\nimport java.util.List;\n\nclass Plain {}\n").unwrap();
assert!(!uses_lombok(root, None));
// Build output and ignored files don't count.
fs::create_dir_all(root.join("app/build/generated")).unwrap();
fs::write(root.join("app/build/generated/G.java"), "import lombok.Data;\n").unwrap();
fs::write(root.join(".ignore"), "vendor/\n").unwrap();
fs::create_dir_all(root.join("vendor")).unwrap();
fs::write(root.join("vendor/V.java"), "import lombok.Value;\n").unwrap();
assert!(!uses_lombok(root, None));
fs::write(
pkg.join("User.java"),
"/* License */\npackage com.acme;\n\nimport lombok.Data;\n\n@Data\nclass User {}\n",
)
.unwrap();
assert!(uses_lombok(root, None));
fs::write(pkg.join("User.java"), "package com.acme;\n\n@lombok.Getter\nclass User {}\n").unwrap();
assert!(uses_lombok(root, None));
fs::write(pkg.join("User.java"), "package com.acme;\nimport static lombok.AccessLevel.PRIVATE;\n").unwrap();
assert!(uses_lombok(root, None));
}
#[test]
fn the_installed_agent_applies_without_detection() {
let tmp = tempfile::tempdir().unwrap();
let repo = tmp.path().join("m2");
assert_eq!(lombok_agent(tmp.path(), None, None, Some(repo.clone())), LombokAgent::Unused);
let managed = lombok_agent(tmp.path(), None, Some("/managed/lombok.jar".into()), Some(repo));
assert_eq!(managed.jar(), Some(Path::new("/managed/lombok.jar")));
assert_eq!(managed.problem(), None);
}
#[test]
+9 -7
Переглянути файл
@@ -55,13 +55,15 @@ pub async fn java_resolve_launch(
let shorten = ide_run::java_cmdline::shortening_for(&exec, &info.class_paths, &info.module_paths, cfg!(windows));
info.shorten_command_line = Some(shorten.as_str().to_owned());
if info.error_count > 0 {
let vm_args = &settings.languages.java.jdtls_vm_args;
let lombok = ide_sdk::lombok::lombok_agent(
&project.root,
ide_sdk::lombok::agent_in_args(vm_args.split_whitespace()),
ide_tools::managed_bundle(&state.data_dir, "lombok"),
ide_sdk::apply::maven_local_repository(&project.root, &settings),
);
let own = ide_sdk::lombok::agent_in_args(settings.languages.java.jdtls_vm_args.split_whitespace());
let managed = ide_tools::managed_bundle(&state.data_dir, "lombok");
let repo = ide_sdk::apply::maven_local_repository(&project.root, &settings);
let root = project.root.clone();
// Detection may read the project's sources.
let lombok =
tauri::async_runtime::spawn_blocking(move || ide_sdk::lombok::lombok_agent(&root, own, managed, repo))
.await
.map_err(|e| IdeError::internal(e.to_string()))?;
info.hints.extend(lombok.problem());
}
Ok(info)
+1 -1
Переглянути файл
@@ -249,7 +249,7 @@ export function JavaPage() {
<Row
label="JVM options"
htmlFor={id}
hint="Passed to the JVM running jdtls (java.jdt.ls.vmargs), e.g. -Xmx2g. A Lombok agent is added automatically when the project uses Lombok."
hint="Passed to the JVM running jdtls (java.jdt.ls.vmargs), e.g. -Xmx2g. A Lombok agent is added automatically: the one installed in Language Servers, else, when the project uses Lombok (its build, an inherited parent POM or Gradle build, or its sources' imports), the newest from the Maven / Gradle caches."
>
<Input
id={id}
+1 -1
Переглянути файл
@@ -1357,7 +1357,7 @@
"Passed as -Dmaven.repo.local.": "以 -Dmaven.repo.local 传入。",
"Passed as -s (mirrors, servers, proxies).": "以 -s 传入(镜像、服务器、代理)。",
"Passed to rustfmt when reformatting, e.g. --edition 2024": "重新格式化时传递给 rustfmt,例如 --edition 2024",
"Passed to the JVM running jdtls (java.jdt.ls.vmargs), e.g. -Xmx2g. A Lombok agent is added automatically when the project uses Lombok.": "传递给运行 jdtls 的 JVM (java.jdt.ls.vmargs),例如 -Xmx2g。项目使用 Lombok 时会自动添加 Lombok 代理。",
"Passed to the JVM running jdtls (java.jdt.ls.vmargs), e.g. -Xmx2g. A Lombok agent is added automatically: the one installed in Language Servers, else, when the project uses Lombok (its build, an inherited parent POM or Gradle build, or its sources' imports), the newest from the Maven / Gradle caches.": "传递给运行 jdtls 的 JVM (java.jdt.ls.vmargs),例如 -Xmx2g。会自动添加 Lombok 代理:优先使用在“语言服务器”中安装的版本;否则当项目使用 Lombok(构建文件、继承的父 POM 或 Gradle 构建、源码中的 import)时,使用 Maven / Gradle 缓存中最新的版本。",
"Pop stash": "弹出储藏",
"Preview Source": "预览源代码",
"Reinstate index": "恢复索引",