fix(java): bundle Lombok agent with Equinox config lookup fix (#642)

lombok.config was ignored in Java builds (e.g. lombok.accessors.chain=true produced void setters), and jdt.ls wrote those classes into target/classes, breaking later Maven builds.

Root cause: Lombok calls getModuleClassLoader(boolean), which newer Equinox removed; the exception is swallowed, Lombok resolves the wrong source path and finds no lombok.config at all, so every config key falls back to its default. Affects every Java project using lombok.config.

Fix: ship Lombok 1.18.48 with the upstream fix projectlombok/lombok#4059 (lombok-1.18.48-openidea.1, embedded in the app, no download) as the jdt.ls agent; the versioned path also resets the jdt.ls workspace so stale output is rebuilt. A user-configured agent still takes precedence. Return to Maven Central once an official release includes the fix (source and checksum in plugins/lombok/README.md).

Verified on macOS: 5 clean builds with chained setters, identical output to official 1.18.48 without lombok.config, the app rebuilds already-poisoned target/classes, and Run works. Windows and Linux not run.

Fixes #565
Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-11-01 12:08:44 +08:00
1 джерело ad80e903e3
коміт 7a58d9f253
12 files changed
+332 -17

No files matched your search

+14
Переглянути файл
@@ -717,6 +717,20 @@ mod tests {
configure_jdtls(&mut plain, Path::new("/p"), Path::new("/data"), &[], None, None, JDTLS_MIN_JAVA, &[], None);
let data = |c: &LanguageServerConfig| c.args[c.args.iter().position(|a| a == "-data").unwrap() + 1].clone();
assert_ne!(data(&cfg), data(&plain), "a separate workspace, so everything is rebuilt with the agent");
let patched = Path::new("/managed/lombok-1.18.48-openidea.1.jar");
let mut fixed = jdtls();
configure_jdtls(
&mut fixed,
Path::new("/p"),
Path::new("/data"),
&[],
None,
None,
JDTLS_MIN_JAVA,
&[],
Some(patched),
);
assert_ne!(data(&cfg), data(&fixed), "the patch must rebuild classes compiled with the official agent");
let before = cfg.clone();
configure_jdtls(&mut cfg, Path::new("/p"), Path::new("/data"), &[], None, None, JDTLS_MIN_JAVA, &[], Some(jar));
assert_eq!(cfg, before, "idempotent");
+20 -3
Переглянути файл
@@ -1,6 +1,6 @@
//! Lombok for jdtls: ECJ only generates Lombok members when the language
//! server's JVM runs `-javaagent:lombok.jar`, so a project using Lombok gets the
//! newest jar available, managed (installed by the IDE) or in the Maven / Gradle caches.
//! app-provided jar, or falls back to the newest release in the Maven / Gradle caches.
use crate::home_dir;
use std::path::{Path, PathBuf};
@@ -18,7 +18,7 @@ pub enum LombokAgent {
Missing,
Found {
jar: PathBuf,
/// `None` for the managed jar (the latest release when installed).
/// `None` for the app-provided jar (its version is shown by the tool registry).
version: Option<String>,
},
}
@@ -64,7 +64,7 @@ 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, always attached: it is inert
/// options) wins, then `managed` (the app-provided 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(
@@ -499,6 +499,23 @@ mod tests {
assert_eq!(managed.problem(), None);
}
#[test]
fn the_patched_agent_wins_over_project_caches_but_respects_explicit_overrides() {
let tmp = tempfile::tempdir().unwrap();
fs::write(tmp.path().join("pom.xml"), LOMBOK_DEP).unwrap();
let repo = tmp.path().join("m2");
let cache = repo.join("org/projectlombok/lombok/1.18.48");
fs::create_dir_all(&cache).unwrap();
fs::write(cache.join("lombok-1.18.48.jar"), b"official agent").unwrap();
let patched = PathBuf::from("/managed/lombok-1.18.48-openidea.1.jar");
let selected = lombok_agent(tmp.path(), None, Some(patched.clone()), Some(repo.clone()));
assert_eq!(selected.jar(), Some(patched.as_path()));
assert_eq!(selected.problem(), None);
let own = PathBuf::from("/custom/lombok.jar");
let selected = lombok_agent(tmp.path(), Some(own.clone()), Some(patched), Some(repo));
assert_eq!(selected.jar(), Some(own.as_path()));
}
#[test]
fn picks_the_newest_cached_release() {
let tmp = tempfile::tempdir().unwrap();
+28
Переглянути файл
@@ -0,0 +1,28 @@
//! Versioned app-owned plugin jars, shared by tool detection and language-server launches.
use crate::{Method, ToolSpec};
use std::path::{Path, PathBuf};
pub(crate) fn install(root: &Path, spec: &ToolSpec) -> std::io::Result<PathBuf> {
let Method::Bundled { filename, bytes, .. } = spec.method else { unreachable!("bundled tool") };
let dir = root.join(spec.name).join("bin");
// The agent path participates in the JDT LS workspace key: upgrades must
// discard incremental state compiled with the previous agent.
let path = dir.join(filename);
if std::fs::read(&path).is_ok_and(|old| old == bytes) {
return Ok(path);
}
std::fs::create_dir_all(&dir)?;
let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_nanos();
let temporary = dir.join(format!(".{filename}-{}-{nonce}.tmp", std::process::id()));
let result = std::fs::write(&temporary, bytes).and_then(|_| std::fs::rename(&temporary, &path));
if let Err(error) = result {
let _ = std::fs::remove_file(&temporary);
// Another window may have installed the same bytes concurrently.
if !std::fs::read(&path).is_ok_and(|old| old == bytes) {
tracing::warn!(tool = spec.name, %error, "Bundled plugin unavailable");
return Err(error);
}
}
Ok(path)
}
+21 -4
Переглянути файл
@@ -10,6 +10,7 @@
//! [`tools_bin_dirs`] on the process `PATH` at startup.
pub mod archive;
mod bundled;
pub mod jdk;
pub mod platform;
mod process;
@@ -146,7 +147,7 @@ fn managed_bin_dir(root: &Path, spec: &ToolSpec, platform: Platform) -> Option<P
Method::PipVenv { .. } => {
Some(root.join(spec.name).join("venv").join(if platform.os == Os::Windows { "Scripts" } else { "bin" }))
}
Method::Rustup { .. } | Method::PipUser { .. } | Method::PipTarget { .. } => None,
Method::Bundled { .. } | Method::Rustup { .. } | Method::PipUser { .. } | Method::PipTarget { .. } => None,
}
}
@@ -389,9 +390,12 @@ fn is_bundle(command: &str) -> bool {
command.ends_with(".jar")
}
/// Path of an installed plugin bundle (`<tools>/<name>/bin/<jar>`), if present.
/// Path of an app-provided or installed plugin jar. Embedded jars are materialized on demand.
pub fn managed_bundle(app_data: &Path, name: &str) -> Option<PathBuf> {
let spec = registry::find(name)?;
if matches!(spec.method, Method::Bundled { .. }) {
return bundled::install(&tools_dir(app_data), spec).ok();
}
let jar = spec.commands.first().filter(|c| is_bundle(c))?;
let path = tools_dir(app_data).join(spec.name).join("bin").join(jar);
path.is_file().then_some(path)
@@ -542,6 +546,7 @@ impl Tools {
fn method_missing(&self, method: Method) -> Option<String> {
let need = |ok: bool, msg: &str| (!ok).then(|| msg.to_string());
match method {
Method::Bundled { .. } => None,
Method::Rustup { .. } => need(self.which("rustup").is_some(), "rustup is required (https://rustup.rs)"),
Method::Npm { .. } => need(
self.which_any(&["npm", "pnpm"]).is_some(),
@@ -604,7 +609,8 @@ impl Tools {
};
let (status, path, version) = self.detect(spec);
let unavailable_reason = self.unavailable_reason(spec);
let can_uninstall = status == ToolStatus::Installed;
let bundled = matches!(spec.method, Method::Bundled { .. });
let can_uninstall = status == ToolStatus::Installed && !bundled;
Some(ToolInfo {
name: spec.name.to_string(),
display_name: spec.display.to_string(),
@@ -615,7 +621,7 @@ impl Tools {
path: path.map(Into::into),
version,
install_method: Some(self.install_method(spec).label().to_string()),
can_install: unavailable_reason.is_none(),
can_install: unavailable_reason.is_none() && !bundled,
unavailable_reason,
can_uninstall,
install_hint,
@@ -654,6 +660,12 @@ impl Tools {
}
fn detect(&self, spec: &ToolSpec) -> (ToolStatus, Option<PathBuf>, Option<String>) {
if let Method::Bundled { version, .. } = spec.method {
return match bundled::install(&self.root, spec) {
Ok(path) => (ToolStatus::Installed, Some(path), Some(version.to_owned())),
Err(_) => (ToolStatus::Missing, None, None),
};
}
let main = spec.commands[0];
let managed = managed_bin_dir(&self.root, spec, self.platform).and_then(|d| self.exe_in(&d, main));
if let Some(path) = managed {
@@ -782,6 +794,9 @@ impl Tools {
let method = self.install_method(spec);
emit(InstallEvent::Line { text: format!("Installing {} via {}…", spec.display, method.label()) });
match method {
Method::Bundled { .. } => {
bundled::install(&self.root, spec)?;
}
Method::Rustup { component } => {
let mut c = self.command(self.which("rustup").expect("checked"));
c.args(["component", "add", component]);
@@ -1188,6 +1203,8 @@ impl Tools {
let cancel = CancellationToken::new();
let quiet = |_: InstallEvent| {};
match self.install_method(spec) {
// App-owned resources remain available for every project/window.
Method::Bundled { .. } => {}
Method::Rustup { component } => {
let rustup = self.which("rustup").ok_or_else(|| IdeError::unavailable("rustup not found"))?;
let mut c = self.command(rustup);
+10 -9
Переглянути файл
@@ -31,6 +31,8 @@ pub enum ReleaseSource {
#[derive(Clone, Copy, Debug)]
pub enum Method {
/// An app-owned plugin jar, materialized without a network download.
Bundled { filename: &'static str, version: &'static str, bytes: &'static [u8] },
/// `rustup component add <component>`.
Rustup { component: &'static str },
/// npm / pnpm packages installed into `<tools>/node_modules`; the first is the primary one.
@@ -56,6 +58,7 @@ pub enum Method {
impl Method {
pub fn label(&self) -> &'static str {
match self {
Method::Bundled { .. } => "Bundled",
Method::Rustup { .. } => "rustup",
Method::Npm { .. } => "npm",
Method::Go { .. } => "go install",
@@ -578,15 +581,13 @@ pub static TOOLS: &[ToolSpec] = &[
display: "Lombok (jdtls agent)",
kind: Plugin,
commands: &["lombok.jar"],
// Newer than the Lombok a project pins: old ones fail inside current ECJ.
method: Method::Release {
source: ReleaseSource::MavenCentral { group: "org.projectlombok", artifact: "lombok" },
assets: &[
any_arch(Mac, "lombok-*.jar"),
any_arch(Linux, "lombok-*.jar"),
any_arch(Windows, "lombok-*.jar"),
],
locate: &["lombok.jar"],
// Equinox removed getModuleClassLoader(boolean), breaking lombok.config lookup.
// Return to Maven Central once an official release includes lombok#4059.
// Source commit, build instructions and checksum: plugins/lombok/README.md.
method: Method::Bundled {
filename: "lombok-1.18.48-openidea.1.jar",
version: "1.18.48-openidea.1",
bytes: include_bytes!("../../../plugins/lombok/lombok-1.18.48-openidea.1.jar"),
},
fallback: None,
version: V,
-1
Переглянути файл
@@ -434,7 +434,6 @@ mod tests {
assert_eq!(d.version, "1.18.48");
assert_eq!(d.name, "lombok-1.18.48.jar");
assert_eq!(d.url, "https://repo1.maven.org/maven2/org/projectlombok/lombok/1.18.48/lombok-1.18.48.jar");
assert!(select_asset(&[d.name.as_str()], find("lombok").unwrap().asset_rules(), LINUX).is_some());
assert!(maven_pick("g", "a", "<metadata/>").is_err());
assert!(maven_pick("g", "a", "<release>../x</release>").is_err());
}
+55
Переглянути файл
@@ -43,6 +43,61 @@ fn find<'a>(list: &'a [ToolInfo], name: &str) -> &'a ToolInfo {
list.iter().find(|t| t.name == name).unwrap_or_else(|| panic!("{name} not listed"))
}
#[test]
fn bundled_lombok_replaces_legacy_installs_on_every_platform_without_downloads() {
for os in [Os::Mac, Os::Linux, Os::Windows] {
for arch in [platform::Arch::Arm64, platform::Arch::X64] {
let env = Env::new();
let legacy = env.root.join("lombok/bin/lombok.jar");
fs::create_dir_all(legacy.parent().unwrap()).unwrap();
fs::write(&legacy, b"old official agent").unwrap();
fs::write(env.root.join("lombok").join(MANIFEST), r#"{"version":"1.18.48"}"#).unwrap();
let tools = Tools::with_paths(env.root.clone(), OsString::new(), Platform { os, arch });
let info = tools.info("lombok", &[]).unwrap();
let path = managed_bundle(env.root.parent().unwrap(), "lombok").unwrap();
assert_eq!(info.status, ToolStatus::Installed);
assert_eq!(info.version.as_deref(), Some("1.18.48-openidea.1"));
assert_eq!(info.path, Some(path.clone().into()));
assert_eq!(path.file_name().unwrap(), "lombok-1.18.48-openidea.1.jar");
assert!(!info.can_install && !info.can_uninstall);
assert_eq!(tools.unavailable_reason(registry::find("lombok").unwrap()), None);
assert_eq!(fs::read(&legacy).unwrap(), b"old official agent", "other windows may still use it");
}
}
}
#[test]
fn bundled_lombok_is_available_offline_and_repairs_missing_or_corrupt_files() {
let env = Env::new();
let data = env.root.parent().unwrap();
let agent = managed_bundle(data, "lombok").unwrap();
let checksum = include_str!("../../../plugins/lombok/SHA256SUMS").split_whitespace().next().unwrap();
assert_eq!(sources::sha256_file(&agent).unwrap(), checksum);
for contents in [None, Some(b"incomplete jar".as_slice())] {
if let Some(contents) = contents {
fs::write(&agent, contents).unwrap();
} else {
fs::remove_file(&agent).unwrap();
}
assert_eq!(managed_bundle(data, "lombok"), Some(agent.clone()));
assert_eq!(sources::sha256_file(&agent).unwrap(), checksum);
}
assert_eq!(fs::read_dir(agent.parent().unwrap()).unwrap().count(), 1, "no temporary jars left behind");
}
#[test]
fn concurrent_windows_get_the_same_complete_bundled_lombok() {
let env = Env::new();
let data = env.root.parent().unwrap();
std::thread::scope(|scope| {
let windows: Vec<_> = (0..8).map(|_| scope.spawn(|| managed_bundle(data, "lombok").unwrap())).collect();
let paths: Vec<_> = windows.into_iter().map(|window| window.join().unwrap()).collect();
assert!(paths.iter().all(|path| path == &paths[0]));
let checksum = include_str!("../../../plugins/lombok/SHA256SUMS").split_whitespace().next().unwrap();
assert_eq!(sources::sha256_file(&paths[0]).unwrap(), checksum);
});
}
#[test]
fn version_parsing() {
assert_eq!(parse_version("rust-analyzer 1.85.0 (4d91de4e 2025-02-17)").as_deref(), Some("1.85.0"));