From acf69310178522128068df980c92f16c8ef0c43b Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Fri, 23 Oct 2026 18:46:13 +0800 Subject: [PATCH] fix(java): Run jdtls with a Lombok agent and show build errors at launch (#9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(java): Run jdtls with a Lombok agent and show build errors at launch jdtls compiles with ECJ, which only generates Lombok members when the server JVM runs -javaagent:lombok.jar. Nothing put it there, so @Data / @Setter classes lost their accessors and Run launched stale classes with "Unresolved compilation problem" stubs and no explanation. - Projects whose build files mention Lombok get the newest jar: the IDE-managed one (new "Lombok (jdtls agent)" tool, latest release from Maven Central), else ~/.m2 (incl. settings.xml localRepository) or the Gradle cache. Each agent gets its own jdtls workspace so everything is rebuilt with it. - Settings › Languages & Frameworks › Java › JVM options (java.jdt.ls.vmargs); a Lombok agent there wins. - Run (and Debug, as a notification) list the first compile errors when the build has errors, plus a hint when Lombok is missing or older than 1.18.34. Fixes #3 * fix(java): Base the Lombok hint on the agent jdtls actually runs A Lombok agent in Settings › Java › JVM options wins over the managed and cached jars, but the launch hint still looked only at those: with no cached jar it said Lombok was missing, and with an old cached one it blamed that version although jdtls ran the user's. The hint and the startup warning now use the user's agent (version from its file name). --- crates/ide-core/src/settings.rs | 9 + crates/ide-lsp/src/jdtls.rs | 82 ++++++ crates/ide-lsp/src/server.rs | 6 + crates/ide-sdk/src/apply.rs | 6 + crates/ide-sdk/src/java.rs | 58 +++- crates/ide-sdk/src/lib.rs | 1 + crates/ide-sdk/src/lombok.rs | 272 ++++++++++++++++++ crates/ide-sdk/src/tests.rs | 10 + crates/ide-tools/src/lib.rs | 8 + crates/ide-tools/src/registry.rs | 38 ++- crates/ide-tools/src/sources.rs | 50 ++++ src-tauri/src/commands/java.rs | 9 + src-tauri/src/state.rs | 18 +- src/bindings.ts | 27 +- src/features/debugger/__tests__/java.test.ts | 3 + src/features/debugger/java.ts | 16 +- src/features/run/java.test.ts | 29 ++ src/features/run/store.ts | 4 +- .../settings/__tests__/languages.test.ts | 4 +- .../settings/languages/LanguagePages.tsx | 30 +- src/features/settings/pages/languages.tsx | 12 +- src/features/tools/LanguageServersPage.tsx | 1 + src/platform/javaLaunch.ts | 9 + src/platform/languageSettings.ts | 4 + 24 files changed, 687 insertions(+), 19 deletions(-) create mode 100644 crates/ide-sdk/src/lombok.rs diff --git a/crates/ide-core/src/settings.rs b/crates/ide-core/src/settings.rs index e78c87356..38dece8b1 100644 --- a/crates/ide-core/src/settings.rs +++ b/crates/ide-core/src/settings.rs @@ -335,6 +335,7 @@ pub struct LanguagesSettings { pub rust: RustSettings, pub go: GoSettings, pub typescript: TypeScriptSettings, + pub java: JavaSettings, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] @@ -397,6 +398,14 @@ pub struct GoSettings { pub gopath: String, } +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)] +#[serde(default, rename_all = "camelCase")] +pub struct JavaSettings { + /// Extra JVM options for the Java language server (jdtls), space-separated + /// (VS Code's `java.jdt.ls.vmargs`). + pub jdtls_vm_args: String, +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] #[serde(default, rename_all = "camelCase")] pub struct TypeScriptSettings { diff --git a/crates/ide-lsp/src/jdtls.rs b/crates/ide-lsp/src/jdtls.rs index 8b861964a..a3821b131 100644 --- a/crates/ide-lsp/src/jdtls.rs +++ b/crates/ide-lsp/src/jdtls.rs @@ -24,6 +24,10 @@ const BUILD_TIMEOUT: Duration = Duration::from_secs(300); const COMMAND_TIMEOUT: Duration = Duration::from_secs(120); /// How long to retry while the project is still being imported. const IMPORT_WAIT: Duration = Duration::from_secs(90); +/// Compile errors listed with a launch; the rest are only counted. +const MAX_LAUNCH_ERRORS: usize = 10; +/// Build markers can be published just after `java/buildWorkspace` answers. +const ERRORS_WAIT: Duration = Duration::from_secs(2); /// Result of `java/buildWorkspace` (jdtls `BuildWorkspaceStatus`). #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] @@ -70,6 +74,43 @@ pub struct JavaLaunchInfo { pub java_home: Option, /// The `java` launcher to use (filled in by the caller from the installed JDKs). pub java_exec: Option, + /// First compile errors of the workspace when the build had errors. + pub errors: Vec, + /// All compile errors (`errors` is capped). + pub error_count: u32, + /// Likely causes of the errors worth telling the user (e.g. a missing Lombok agent). + pub hints: Vec, +} + +/// One error diagnostic; `line` / `column` are 1-based for display. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct JavaCompileError { + pub path: ide_core::IpcPath, + pub line: u32, + pub column: u32, + pub message: String, +} + +/// Error diagnostics sorted by file and position, capped at `limit`, and their total. +pub fn compile_errors( + diagnostics: Vec<(PathBuf, lsp_types::Diagnostic)>, + limit: usize, +) -> (Vec, u32) { + let mut errors: Vec = diagnostics + .into_iter() + .filter(|(_, d)| d.severity == Some(lsp_types::DiagnosticSeverity::ERROR)) + .map(|(path, d)| JavaCompileError { + path: path.into(), + line: d.range.start.line + 1, + column: d.range.start.character + 1, + message: d.message.lines().next().unwrap_or_default().to_owned(), + }) + .collect(); + errors.sort_by(|a, b| (&a.path, a.line, a.column).cmp(&(&b.path, b.line, b.column))); + let count = u32::try_from(errors.len()).unwrap_or(u32::MAX); + errors.truncate(limit); + (errors, count) } /// `java.project.getClasspaths` result. @@ -210,6 +251,10 @@ impl LspManager { /// Builds (when asked), then resolves the classpath and JDK of the module at `module`. pub async fn java_launch(&self, module: &Path, test: bool, build: bool) -> IdeResult { let build = if build { Some(self.java_build(module).await?) } else { None }; + let (errors, error_count) = match build { + Some(JavaBuildStatus::WithErrors | JavaBuildStatus::Failed) => self.java_compile_errors(module).await, + _ => (Vec::new(), 0), + }; let cp = self.java_classpath(module, test).await?; let java_home = match self.java_vm_location(module).await { Ok(home) => home, @@ -225,9 +270,24 @@ impl LspManager { module_paths: cp.module_paths, java_home: java_home.map(Into::into), java_exec: None, + errors, + error_count, + hints: Vec::new(), }) } + /// Compile errors jdtls reports for its workspace (all modules it imported). + async fn java_compile_errors(&self, module: &Path) -> (Vec, u32) { + let Ok((server, _)) = self.jdtls(module).await else { return (Vec::new(), 0) }; + let collect = || compile_errors(server.diagnostics_snapshot(), MAX_LAUNCH_ERRORS); + let found = collect(); + if found.1 > 0 { + return found; + } + let _ = tokio::time::timeout(ERRORS_WAIT, server.published_notify.notified()).await; + collect() + } + /// Starts (or reuses) java-debug's DAP server inside jdtls and returns its port. pub async fn java_debug_server(&self, path: &Path) -> IdeResult { let v = self.jdtls_command(path, "vscode.java.startDebugSession", Vec::new()).await.map_err(|e| { @@ -256,6 +316,28 @@ impl LspManager { mod tests { use super::*; + #[test] + fn launch_errors_are_sorted_and_capped() { + let diag = |line: u32, severity, message: &str| lsp_types::Diagnostic { + range: lsp_types::Range::new(lsp_types::Position::new(line, 4), lsp_types::Position::new(line, 9)), + severity: Some(severity), + message: message.into(), + ..Default::default() + }; + let (errors, count) = compile_errors( + vec![ + (PathBuf::from("/p/B.java"), diag(1, lsp_types::DiagnosticSeverity::ERROR, "b")), + (PathBuf::from("/p/A.java"), diag(9, lsp_types::DiagnosticSeverity::ERROR, "a2\nmore")), + (PathBuf::from("/p/A.java"), diag(2, lsp_types::DiagnosticSeverity::WARNING, "warning")), + (PathBuf::from("/p/A.java"), diag(3, lsp_types::DiagnosticSeverity::ERROR, "a1")), + ], + 2, + ); + assert_eq!(count, 3); + let shown: Vec<(u32, u32, &str)> = errors.iter().map(|e| (e.line, e.column, e.message.as_str())).collect(); + assert_eq!(shown, [(4, 5, "a1"), (10, 5, "a2")]); + } + #[test] fn classpaths() { let v = json!({ diff --git a/crates/ide-lsp/src/server.rs b/crates/ide-lsp/src/server.rs index 96b82e58c..4568ca442 100644 --- a/crates/ide-lsp/src/server.rs +++ b/crates/ide-lsp/src/server.rs @@ -330,6 +330,12 @@ impl Server { self.diagnostics.lock().get(path).cloned().unwrap_or_default() } + /// Every published diagnostic, by file. + pub fn diagnostics_snapshot(&self) -> Vec<(PathBuf, lsp::Diagnostic)> { + let d = self.diagnostics.lock(); + d.iter().flat_map(|(p, items)| items.iter().map(move |i| (p.clone(), i.clone()))).collect() + } + pub fn take_diagnostic_paths(&self) -> Vec { self.published.lock().clear(); self.diagnostics.lock().drain().map(|(p, _)| p).collect() diff --git a/crates/ide-sdk/src/apply.rs b/crates/ide-sdk/src/apply.rs index 47bbe4369..50957d344 100644 --- a/crates/ide-sdk/src/apply.rs +++ b/crates/ide-sdk/src/apply.rs @@ -166,6 +166,12 @@ pub fn configure_server(cfg: &mut LanguageServerConfig, root: &Path, server_root } } + if name == "jdtls" { + // The jdtls launcher forwards `--jvm-arg=` values to the JVM it starts. + let args = s.languages.java.jdtls_vm_args.split_whitespace().map(|a| format!("--jvm-arg={a}")); + cfg.args.extend(args); + } + if let Some(lib) = tsdk(server_root, s).or_else(|| tsdk(root, s)) { let lib_s = lib.to_string_lossy().into_owned(); match name { diff --git a/crates/ide-sdk/src/java.rs b/crates/ide-sdk/src/java.rs index c21c6eebe..6a38e7d03 100644 --- a/crates/ide-sdk/src/java.rs +++ b/crates/ide-sdk/src/java.rs @@ -266,12 +266,22 @@ pub fn configure_jdtls( jdks: &[Jdk], min_java: u32, bundles: &[PathBuf], + lombok: Option<&Path>, ) { if cfg.name != "jdtls" { return; } + // A Lombok agent from the user's JVM arguments wins. + let own_agent = crate::lombok::agent_in_args(cfg.args.iter().map(String::as_str)); + let agent = own_agent.as_deref().or(lombok); if !cfg.args.iter().any(|a| a == "-data") { - let dir = data_dir.join("lsp").join("jdtls").join(path_key(server_root)); + // ECJ builds incrementally: classes compiled without (or with another) Lombok + // agent would be kept, so each agent gets its own workspace and a full build. + let key = match agent { + Some(jar) => format!("{}-{}", path_key(server_root), path_key(jar)), + None => path_key(server_root), + }; + let dir = data_dir.join("lsp").join("jdtls").join(key); cfg.args.extend(["-data".to_owned(), dir.to_string_lossy().into_owned()]); } // jdtls reads this one as a system property (its file-system bundle @@ -279,6 +289,11 @@ pub fn configure_jdtls( if !cfg.args.iter().any(|a| a.contains(METADATA_PROPERTY)) { cfg.args.push(format!("--jvm-arg=-D{METADATA_PROPERTY}=false")); } + if own_agent.is_none() + && let Some(jar) = lombok + { + cfg.args.push(format!("--jvm-arg=-javaagent:{}", jar.to_string_lossy())); + } if !cfg.env.contains_key("JAVA_HOME") { match jdtls_runtime(jdks, min_java) { Some(jdk) => { @@ -369,7 +384,7 @@ pub fn project_java_version(module_dir: &Path) -> Option { None } -fn xml_value(text: &str, tag: &str) -> Option { +pub(crate) fn xml_value(text: &str, tag: &str) -> Option { let open = format!("<{tag}>"); let start = text.find(&open)? + open.len(); let end = text[start..].find('<')?; @@ -409,7 +424,7 @@ mod tests { Jdk { major: 8, home: PathBuf::from("/jdk8") }, ]; let bundles = [PathBuf::from("/tools/java-debug/bin/com.microsoft.java.debug.plugin.jar")]; - configure_jdtls(&mut cfg, Path::new("/p/demo"), Path::new("/data"), &jdks, JDTLS_MIN_JAVA, &bundles); + configure_jdtls(&mut cfg, Path::new("/p/demo"), Path::new("/data"), &jdks, JDTLS_MIN_JAVA, &bundles, None); assert_eq!(cfg.args[0], "-data"); assert!(cfg.args[1].starts_with("/data/lsp/jdtls/"), "{}", cfg.args[1]); assert_eq!(cfg.args[2], "--jvm-arg=-Djava.import.generatesMetadataFilesAtProjectRoot=false"); @@ -424,32 +439,57 @@ mod tests { // Deterministic (reconfigure compares configs) and idempotent. let before = cfg.clone(); - configure_jdtls(&mut cfg, Path::new("/p/demo"), Path::new("/data"), &jdks, JDTLS_MIN_JAVA, &bundles); + configure_jdtls(&mut cfg, Path::new("/p/demo"), Path::new("/data"), &jdks, JDTLS_MIN_JAVA, &bundles, None); assert_eq!(cfg, before); let mut other: LanguageServerConfig = toml::from_str("name = \"pyright\"\ncommand = \"x\"").unwrap(); - configure_jdtls(&mut other, Path::new("/p"), Path::new("/data"), &jdks, JDTLS_MIN_JAVA, &bundles); + configure_jdtls(&mut other, Path::new("/p"), Path::new("/data"), &jdks, JDTLS_MIN_JAVA, &bundles, None); assert!(other.args.is_empty() && other.initialization_options.is_none()); } + #[test] + fn jdtls_lombok_agent() { + let jdtls = || -> LanguageServerConfig { toml::from_str("name = \"jdtls\"\ncommand = \"jdtls\"").unwrap() }; + let jar = Path::new("/m2/lombok-1.18.48.jar"); + let mut cfg = jdtls(); + configure_jdtls(&mut cfg, Path::new("/p"), Path::new("/data"), &[], JDTLS_MIN_JAVA, &[], Some(jar)); + let agent = format!("--jvm-arg=-javaagent:{}", jar.to_string_lossy()); + assert!(cfg.args.contains(&agent), "{:?}", cfg.args); + let mut plain = jdtls(); + configure_jdtls(&mut plain, Path::new("/p"), Path::new("/data"), &[], JDTLS_MIN_JAVA, &[], None); + assert_ne!(cfg.args[1], plain.args[1], "a separate workspace, so everything is rebuilt with the agent"); + let before = cfg.clone(); + configure_jdtls(&mut cfg, Path::new("/p"), Path::new("/data"), &[], JDTLS_MIN_JAVA, &[], Some(jar)); + assert_eq!(cfg, before, "idempotent"); + + let mut cfg = jdtls(); + cfg.args.push("--jvm-arg=-javaagent:/own/lombok.jar".into()); + configure_jdtls(&mut cfg, Path::new("/p"), Path::new("/data"), &[], JDTLS_MIN_JAVA, &[], Some(jar)); + assert!(!cfg.args.contains(&agent), "the user's agent wins"); + let mut own = jdtls(); + own.args.push("--jvm-arg=-javaagent:/own/lombok.jar".into()); + configure_jdtls(&mut own, Path::new("/p"), Path::new("/data"), &[], JDTLS_MIN_JAVA, &[], None); + assert_eq!(cfg.args, own.args, "its workspace follows the agent in use"); + } + #[test] fn jdtls_needs_a_new_enough_jdk_to_run() { let jdtls = || -> LanguageServerConfig { toml::from_str("name = \"jdtls\"\ncommand = \"jdtls\"").unwrap() }; let old = [Jdk { major: 17, home: PathBuf::from("/jdk17") }, Jdk { major: 8, home: PathBuf::from("/jdk8") }]; let mut cfg = jdtls(); - configure_jdtls(&mut cfg, Path::new("/p"), Path::new("/data"), &old, JDTLS_MIN_JAVA, &[]); + configure_jdtls(&mut cfg, Path::new("/p"), Path::new("/data"), &old, JDTLS_MIN_JAVA, &[], None); assert!(!cfg.env.contains_key("JAVA_HOME")); let err = cfg.launch_error.unwrap(); assert!(err.contains("JDK 21 or newer") && err.contains("found JDK 8, 17"), "{err}"); let mut cfg = jdtls(); - configure_jdtls(&mut cfg, Path::new("/p"), Path::new("/data"), &[], JDTLS_MIN_JAVA, &[]); + configure_jdtls(&mut cfg, Path::new("/p"), Path::new("/data"), &[], JDTLS_MIN_JAVA, &[], None); assert!(cfg.launch_error.unwrap().contains("no JDK was found")); // An explicit JAVA_HOME is the user's call. let mut cfg = jdtls(); cfg.env.insert("JAVA_HOME".into(), "/custom".into()); - configure_jdtls(&mut cfg, Path::new("/p"), Path::new("/data"), &old, JDTLS_MIN_JAVA, &[]); + configure_jdtls(&mut cfg, Path::new("/p"), Path::new("/data"), &old, JDTLS_MIN_JAVA, &[], None); assert_eq!((cfg.env["JAVA_HOME"].as_str(), cfg.launch_error), ("/custom", None)); let mixed = [old[0].clone(), Jdk { major: 21, home: PathBuf::from("/jdk21") }]; @@ -457,7 +497,7 @@ mod tests { // A jdtls whose bundles need 25 doesn't start on 21, and says what it needs. let mut cfg = jdtls(); - configure_jdtls(&mut cfg, Path::new("/p"), Path::new("/data"), &mixed, 25, &[]); + configure_jdtls(&mut cfg, Path::new("/p"), Path::new("/data"), &mixed, 25, &[], None); let err = cfg.launch_error.unwrap(); assert!(err.contains("JDK 25 or newer") && err.contains("found JDK 17, 21"), "{err}"); } diff --git a/crates/ide-sdk/src/lib.rs b/crates/ide-sdk/src/lib.rs index 48485aae6..faa821f50 100644 --- a/crates/ide-sdk/src/lib.rs +++ b/crates/ide-sdk/src/lib.rs @@ -10,6 +10,7 @@ mod discover; mod exec; pub mod java; pub mod locate; +pub mod lombok; pub mod python; use serde::{Deserialize, Serialize}; diff --git a/crates/ide-sdk/src/lombok.rs b/crates/ide-sdk/src/lombok.rs new file mode 100644 index 000000000..e812e3cd4 --- /dev/null +++ b/crates/ide-sdk/src/lombok.rs @@ -0,0 +1,272 @@ +//! 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. + +use crate::home_dir; +use std::path::{Path, PathBuf}; + +/// Oldest Lombok whose ECJ handlers work in current jdtls builds (Eclipse 2024-06 support); +/// older ones throw `NoSuchMethodError` and drop every annotation of the compilation unit. +pub const LOMBOK_MIN: [u32; 3] = [1, 18, 34]; + +/// What a jdtls instance gets for Lombok. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum LombokAgent { + /// The project doesn't use Lombok. + Unused, + /// The project uses Lombok but no jar was found. + Missing, + Found { + jar: PathBuf, + /// `None` for the managed jar (the latest release when installed). + version: Option, + }, +} + +impl LombokAgent { + pub fn jar(&self) -> Option<&Path> { + match self { + Self::Found { jar, .. } => Some(jar), + _ => None, + } + } + + /// Too old for current jdtls. + pub fn outdated(&self) -> bool { + matches!(self, Self::Found { version: Some(v), .. } if parse_version(v).is_some_and(|v| v < LOMBOK_MIN)) + } + + /// Why Lombok-generated members may be missing, if they may be. + pub fn problem(&self) -> Option { + const INSTALL: &str = + "Install Lombok in Settings › Languages & Frameworks › Language Servers (jdtls restarts with it)"; + match self { + Self::Missing => Some(format!( + "The project uses Lombok, but no lombok.jar was found for the Java language server, so Lombok-generated \ + members are missing. {INSTALL}." + )), + Self::Found { version: Some(v), .. } if self.outdated() => Some(format!( + "Lombok {v} is too old for the Java language server (needs {}.{}.{} or newer): classes using it may \ + miss generated members. {INSTALL}.", + LOMBOK_MIN[0], LOMBOK_MIN[1], LOMBOK_MIN[2] + )), + _ => None, + } + } +} + +/// The Lombok jar of a `-javaagent:` among JVM arguments (bare or as `--jvm-arg=`). +pub fn agent_in_args<'a>(args: impl IntoIterator) -> Option { + args.into_iter().find_map(|a| { + let jar = a.split_once("-javaagent:")?.1; + jar.to_ascii_lowercase().contains("lombok").then(|| PathBuf::from(jar)) + }) +} + +/// 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. +pub fn lombok_agent(root: &Path, own: Option, managed: Option) -> LombokAgent { + if let Some(jar) = own { + let version = jar + .file_stem() + .and_then(|s| s.to_str()?.strip_prefix("lombok-")) + .filter(|v| parse_version(v).is_some()) + .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 mut dirs = Vec::new(); + if let Some(repo) = maven_repository(home.as_deref()) { + dirs.push(repo.join("org").join("projectlombok").join("lombok")); + } + if let Some(gradle) = + std::env::var_os("GRADLE_USER_HOME").map(PathBuf::from).or_else(|| home.map(|h| h.join(".gradle"))) + { + dirs.push(gradle.join("caches").join("modules-2").join("files-2.1").join("org.projectlombok").join("lombok")); + } + match newest_cached(&dirs) { + Some((version, jar)) => LombokAgent::Found { jar, version: Some(version) }, + None => LombokAgent::Missing, + } +} + +/// 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 { + if dir.join("lombok.config").is_file() { + return true; + } + let Ok(entries) = std::fs::read_dir(dir) else { return false }; + let mut subdirs = Vec::new(); + for e in entries.flatten() { + let name = e.file_name(); + 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()) { + 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; + } + } + subdirs.iter().any(|d| walk(d, depth - 1)) + } + // `gradle/libs.versions.toml` sits one level down, modules usually no deeper than three. + walk(root, 3) +} + +/// `` of `~/.m2/settings.xml`, else `~/.m2/repository`. +fn maven_repository(home: Option<&Path>) -> Option { + let m2 = home?.join(".m2"); + let custom = std::fs::read_to_string(m2.join("settings.xml")) + .ok() + .and_then(|s| crate::java::xml_value(&strip_xml_comments(&s), "localRepository")) + .filter(|v| !v.is_empty() && !v.contains("${")) + .map(PathBuf::from); + Some(custom.unwrap_or_else(|| m2.join("repository"))) +} + +fn strip_xml_comments(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + let mut rest = text; + while let Some(i) = rest.find("").map_or("", |j| &rest[i + j + 3..]); + } + out.push_str(rest); + out +} + +/// Highest release `//…/lombok-.jar` (Maven: directly in +/// the version directory; Gradle: one hash directory below it). +fn newest_cached(dirs: &[PathBuf]) -> Option<(String, PathBuf)> { + let mut best: Option<([u32; 3], String, PathBuf)> = None; + for dir in dirs { + let Ok(entries) = std::fs::read_dir(dir) else { continue }; + for e in entries.flatten() { + let version = e.file_name().to_string_lossy().into_owned(); + let Some(parsed) = parse_version(&version) else { continue }; + if best.as_ref().is_some_and(|(b, ..)| *b >= parsed) { + continue; + } + let name = format!("lombok-{version}.jar"); + let direct = e.path().join(&name); + let jar = if direct.is_file() { + Some(direct) + } else { + std::fs::read_dir(e.path()) + .into_iter() + .flatten() + .flatten() + .map(|h| h.path().join(&name)) + .find(|p| p.is_file()) + }; + if let Some(jar) = jar { + best = Some((parsed, version, jar)); + } + } + } + best.map(|(_, v, jar)| (v, jar)) +} + +/// `1.18.34` → `[1, 18, 34]`; snapshots and other qualifiers don't count as releases. +fn parse_version(v: &str) -> Option<[u32; 3]> { + let mut parts = v.split('.').map(|p| p.parse::().ok()); + let out = [parts.next()??, parts.next()??, parts.next().unwrap_or(Some(0))?]; + parts.next().is_none().then_some(out) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + #[test] + fn detects_lombok_in_build_files() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + fs::write(root.join("pom.xml"), "api").unwrap(); + assert!(!uses_lombok(root)); + fs::create_dir_all(root.join("api/src/main")).unwrap(); + fs::write(root.join("api/src/main/pom.xml"), "lombok").unwrap(); + assert!(!uses_lombok(root), "sources are not scanned"); + fs::write(root.join("api/pom.xml"), "lombok").unwrap(); + assert!(uses_lombok(root)); + + 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())); + } + + #[test] + fn picks_the_newest_cached_release() { + let tmp = tempfile::tempdir().unwrap(); + let m2 = tmp.path().join("m2"); + let gradle = tmp.path().join("gradle"); + for v in ["1.18.26", "1.18.36", "1.18.99-SNAPSHOT"] { + fs::create_dir_all(m2.join(v)).unwrap(); + fs::write(m2.join(v).join(format!("lombok-{v}.jar")), b"PK").unwrap(); + } + fs::create_dir_all(m2.join("1.18.40")).unwrap(); // only a .pom: not usable + fs::create_dir_all(gradle.join("1.18.38/abc123")).unwrap(); + fs::write(gradle.join("1.18.38/abc123/lombok-1.18.38.jar"), b"PK").unwrap(); + let (v, jar) = newest_cached(&[m2.clone(), gradle.clone()]).unwrap(); + assert_eq!(v, "1.18.38"); + assert_eq!(jar, gradle.join("1.18.38/abc123/lombok-1.18.38.jar")); + assert_eq!(newest_cached(std::slice::from_ref(&m2)).unwrap().0, "1.18.36"); + assert_eq!(newest_cached(&[tmp.path().join("none")]), None); + } + + #[test] + fn old_versions_are_reported() { + let found = |v: Option<&str>| LombokAgent::Found { jar: "/l.jar".into(), version: v.map(Into::into) }; + assert!(found(Some("1.18.26")).outdated()); + assert!(found(Some("1.18.26")).problem().unwrap().contains("1.18.26 is too old")); + assert!(!found(Some("1.18.34")).outdated()); + assert_eq!(found(None).problem(), None); + assert!(LombokAgent::Missing.problem().is_some()); + assert_eq!(LombokAgent::Unused.problem(), None); + assert_eq!(parse_version("1.18"), Some([1, 18, 0])); + assert_eq!(parse_version("edge-SNAPSHOT"), None); + } + + #[test] + fn the_users_agent_wins() { + let tmp = tempfile::tempdir().unwrap(); + let own = agent_in_args(["--jvm-arg=-Xmx2g", "--jvm-arg=-javaagent:/opt/lombok-1.18.26.jar"]); + assert_eq!(own.as_deref(), Some(Path::new("/opt/lombok-1.18.26.jar"))); + assert_eq!(agent_in_args(["-javaagent:/opt/other.jar"]), None); + let agent = lombok_agent(tmp.path(), own, Some("/managed/lombok.jar".into())); + assert_eq!(agent.jar(), Some(Path::new("/opt/lombok-1.18.26.jar"))); + assert!(agent.outdated(), "its version is read from the file name"); + let plain = lombok_agent(tmp.path(), agent_in_args(["-javaagent:/opt/lombok.jar"]), None); + assert_eq!(plain.problem(), None, "a user agent is never reported missing"); + } + + #[test] + fn maven_settings_local_repository() { + let tmp = tempfile::tempdir().unwrap(); + let home = tmp.path(); + assert_eq!(maven_repository(Some(home)), Some(home.join(".m2/repository"))); + fs::create_dir_all(home.join(".m2")).unwrap(); + fs::write( + home.join(".m2/settings.xml"), + "/repo", + ) + .unwrap(); + assert_eq!(maven_repository(Some(home)), Some(PathBuf::from("/repo"))); + } +} diff --git a/crates/ide-sdk/src/tests.rs b/crates/ide-sdk/src/tests.rs index d74921218..75d099f2b 100644 --- a/crates/ide-sdk/src/tests.rs +++ b/crates/ide-sdk/src/tests.rs @@ -74,6 +74,16 @@ fn pyright_gets_python_path_and_venv_env() { assert!(cfg.env["PATH"].starts_with(&*tmp.path().join(".venv").join(bin_dir()).to_string_lossy())); } +#[test] +fn jdtls_vm_args() { + let tmp = tempfile::tempdir().unwrap(); + let mut s = Settings::default(); + s.languages.java.jdtls_vm_args = " -Xmx2g -javaagent:/opt/lombok.jar ".into(); + let mut cfg = server("jdtls"); + configure_server(&mut cfg, tmp.path(), tmp.path(), &s); + assert_eq!(cfg.args, ["--jvm-arg=-Xmx2g", "--jvm-arg=-javaagent:/opt/lombok.jar"]); +} + #[test] fn rust_analyzer_check_command_and_toolchain() { if cfg!(windows) { diff --git a/crates/ide-tools/src/lib.rs b/crates/ide-tools/src/lib.rs index a860ced84..c5f7dffe6 100644 --- a/crates/ide-tools/src/lib.rs +++ b/crates/ide-tools/src/lib.rs @@ -216,6 +216,10 @@ fn usages(langs: &[LanguageConfig]) -> BTreeMap { add(&d.adapter, &d.command, ToolKind::Debugger, d.install_hint.as_ref(), &l.name); } } + for (plugin, host) in registry::PLUGIN_HOSTS { + let languages = map.get(*host).map(|u| u.languages.clone()).unwrap_or_default(); + map.entry(plugin.to_string()).or_default().languages = languages; + } map } @@ -734,6 +738,10 @@ impl Tools { let json = http.get_text(&sources::openvsx_latest_url(namespace, extension), cancel)?; sources::openvsx_pick(&json, assets, self.platform) } + ReleaseSource::MavenCentral { group, artifact } => { + let xml = http.get_text(&sources::maven_metadata_url(group, artifact), cancel)?; + sources::maven_pick(group, artifact, &xml) + } } } diff --git a/crates/ide-tools/src/registry.rs b/crates/ide-tools/src/registry.rs index 78ea4cc07..91ff41623 100644 --- a/crates/ide-tools/src/registry.rs +++ b/crates/ide-tools/src/registry.rs @@ -10,6 +10,8 @@ use specta::Type; pub enum ToolKind { LanguageServer, Debugger, + /// Loaded into another tool's process, e.g. a jdtls agent. + Plugin, } /// Where release archives come from. @@ -23,6 +25,8 @@ pub enum ReleaseSource { EclipseJdtls, /// Latest version of a VS Code extension (`.vsix`) on open-vsx.org. OpenVsx { namespace: &'static str, extension: &'static str }, + /// Latest release of a Maven Central artifact (the `.jar` itself). + MavenCentral { group: &'static str, artifact: &'static str }, } #[derive(Clone, Copy, Debug)] @@ -56,6 +60,7 @@ impl Method { Method::Release { source: ReleaseSource::HashiCorp { .. }, .. } => "HashiCorp release", Method::Release { source: ReleaseSource::EclipseJdtls, .. } => "Eclipse download", Method::Release { source: ReleaseSource::OpenVsx { .. }, .. } => "Open VSX", + Method::Release { source: ReleaseSource::MavenCentral { .. }, .. } => "Maven Central", Method::Release { .. } => "GitHub release", } } @@ -86,7 +91,7 @@ impl ToolSpec { use Arch::{Arm64, X64}; use Os::{Linux, Mac, Windows}; -use ToolKind::{Debugger, LanguageServer}; +use ToolKind::{Debugger, LanguageServer, Plugin}; const V: Option<(&str, &[&str])> = None; const fn ver(cmd: &'static str) -> Option<(&'static str, &'static [&'static str])> { @@ -453,6 +458,24 @@ pub static TOOLS: &[ToolSpec] = &[ version: V, homepage: "https://github.com/microsoft/java-debug", }, + ToolSpec { + name: "lombok", + 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"], + }, + version: V, + homepage: "https://projectlombok.org", + }, ToolSpec { name: "kotlin-lsp", display: "Kotlin LSP (JetBrains)", @@ -478,6 +501,9 @@ pub static TOOLS: &[ToolSpec] = &[ }, ]; +/// Plugin tools and the tool that loads them. +pub const PLUGIN_HOSTS: &[(&str, &str)] = &[("lombok", "jdtls")]; + pub fn find(name: &str) -> Option<&'static ToolSpec> { TOOLS.iter().find(|t| t.name == name) } @@ -507,7 +533,7 @@ mod tests { known.insert((d.adapter.clone(), d.command.clone())); } } - for t in TOOLS { + for t in TOOLS.iter().filter(|t| t.kind != Plugin) { assert!( known.contains(&(t.name.to_string(), t.commands[0].to_string())), "{} / {} is not referenced by languages/*.toml", @@ -517,6 +543,14 @@ mod tests { } } + #[test] + fn plugins_have_a_known_host() { + for t in TOOLS.iter().filter(|t| t.kind == Plugin) { + let host = PLUGIN_HOSTS.iter().find(|(p, _)| *p == t.name).map(|(_, h)| *h); + assert!(host.and_then(find).is_some(), "{} has no known host", t.name); + } + } + #[test] fn every_release_tool_covers_mac_linux_windows() { for t in TOOLS { diff --git a/crates/ide-tools/src/sources.rs b/crates/ide-tools/src/sources.rs index 3d1036443..aa10a9852 100644 --- a/crates/ide-tools/src/sources.rs +++ b/crates/ide-tools/src/sources.rs @@ -209,6 +209,39 @@ pub fn openvsx_pick(json: &str, rules: &[AssetRule], platform: Platform) -> IdeR }) } +const MAVEN_CENTRAL: &str = "https://repo1.maven.org/maven2"; + +fn maven_dir(group: &str, artifact: &str) -> String { + format!("{MAVEN_CENTRAL}/{}/{artifact}", group.replace('.', "/")) +} + +pub fn maven_metadata_url(group: &str, artifact: &str) -> String { + format!("{}/maven-metadata.xml", maven_dir(group, artifact)) +} + +/// The `` jar of a `maven-metadata.xml`. Central publishes only SHA-1 +/// sums for most artifacts, so the download is not checksum-verified. +pub fn maven_pick(group: &str, artifact: &str, metadata: &str) -> IdeResult { + let tag = |t: &str| { + let start = metadata.find(&format!("<{t}>"))? + t.len() + 2; + let end = start + metadata[start..].find(&format!(""))?; + Some(metadata[start..end].trim()) + }; + let version = tag("release") + .or_else(|| tag("latest")) + .filter(|v| !v.is_empty() && !v.contains(['/', '\\'])) + .ok_or_else(|| proto(format!("no release in the Maven metadata of {group}:{artifact}")))?; + let name = format!("{artifact}-{version}.jar"); + Ok(Download { + version: version.to_string(), + url: format!("{}/{version}/{name}", maven_dir(group, artifact)), + name, + size: None, + sha256: None, + checksum_url: None, + }) +} + /// Finds the hash for `file_name` in a checksum file: either a bare hex digest /// or `sha256sum` lines (` `). pub fn parse_checksum(text: &str, file_name: &str) -> Option { @@ -322,6 +355,23 @@ mod tests { assert!(openvsx_pick(&other, rules, LINUX).is_err()); } + #[test] + fn maven_central() { + let xml = + "1.18.49-SNAPSHOT1.18.48"; + assert_eq!( + maven_metadata_url("org.projectlombok", "lombok"), + "https://repo1.maven.org/maven2/org/projectlombok/lombok/maven-metadata.xml" + ); + let d = maven_pick("org.projectlombok", "lombok", xml).unwrap(); + 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", "").is_err()); + assert!(maven_pick("g", "a", "../x").is_err()); + } + #[test] fn checksums() { let h = "a".repeat(64); diff --git a/src-tauri/src/commands/java.rs b/src-tauri/src/commands/java.rs index 053e54ac2..8a52d20b3 100644 --- a/src-tauri/src/commands/java.rs +++ b/src-tauri/src/commands/java.rs @@ -47,6 +47,15 @@ pub async fn java_resolve_launch( ide_sdk::java::pick_jdk(&jdks, release).map(|j| ide_sdk::java::java_executable(&j.home)) }); info.java_exec = Some(java.map(|p| p.to_string_lossy().into_owned()).unwrap_or_else(|| "java".to_owned())); + if info.error_count > 0 { + let vm_args = project.settings.effective().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"), + ); + info.hints.extend(lombok.problem()); + } Ok(info) } diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index f406fb828..fde81826b 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -248,7 +248,23 @@ impl AppState { ide_tools::managed_bundle(&data_dir, "java-debug").into_iter().collect(); let min_java = ide_sdk::java::jdtls_min_java(cfg); let jdks = ide_sdk::java::jdtls_jdks(min_java); - ide_sdk::java::configure_jdtls(cfg, server_root, &data_dir, &jdks, min_java, &bundles); + let lombok = ide_sdk::lombok::lombok_agent( + server_root, + ide_sdk::lombok::agent_in_args(cfg.args.iter().map(String::as_str)), + ide_tools::managed_bundle(&data_dir, "lombok"), + ); + if let Some(problem) = lombok.problem() { + tracing::warn!("{problem}"); + } + ide_sdk::java::configure_jdtls( + cfg, + server_root, + &data_dir, + &jdks, + min_java, + &bundles, + lombok.jar(), + ); } })); } diff --git a/src/bindings.ts b/src/bindings.ts index c027c7898..119fb7d66 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -2301,6 +2301,14 @@ export type JavaBuildStatus = "failed" | "succeeded" | /** Compiled, but some project has compile errors. */ "withErrors" | "cancelled"; +/** One error diagnostic; `line` / `column` are 1-based for display. */ +export type JavaCompileError = { + path: IpcPath, + line: number, + column: number, + message: string, +}; + /** Everything needed to launch a class of a module: build outcome, classpath and JDK. */ export type JavaLaunchInfo = { /** `None` when no build was requested. */ @@ -2312,6 +2320,20 @@ export type JavaLaunchInfo = { javaHome: IpcPath | null, /** The `java` launcher to use (filled in by the caller from the installed JDKs). */ javaExec: string | null, + /** First compile errors of the workspace when the build had errors. */ + errors: JavaCompileError[], + /** All compile errors (`errors` is capped). */ + errorCount: number, + /** Likely causes of the errors worth telling the user (e.g. a missing Lombok agent). */ + hints: string[], +}; + +export type JavaSettings = { + /** + * Extra JVM options for the Java language server (jdtls), space-separated + * (VS Code's `java.jdt.ls.vmargs`). + */ + jdtlsVmArgs?: string, }; /** Editor › Code Style › › Tabs and Indents (unset fields inherit). */ @@ -2371,6 +2393,7 @@ export type LanguagesSettings = { rust?: RustSettings, go?: GoSettings, typescript?: TypeScriptSettings, + java?: JavaSettings, }; /** What a freshly opened window should do once its frontend is up. */ @@ -3778,7 +3801,9 @@ export type ToolInfo = { homepage: string | null, }; -export type ToolKind = "languageServer" | "debugger"; +export type ToolKind = "languageServer" | "debugger" | +/** Loaded into another tool's process, e.g. a jdtls agent. */ +"plugin"; export type ToolStatus = /** Installed by the IDE (managed tools dir or a toolchain component it manages). */ diff --git a/src/features/debugger/__tests__/java.test.ts b/src/features/debugger/__tests__/java.test.ts index a444b537b..a28e595b7 100644 --- a/src/features/debugger/__tests__/java.test.ts +++ b/src/features/debugger/__tests__/java.test.ts @@ -18,6 +18,9 @@ vi.mock("@/platform/ipc", async (orig) => { modulePaths: [], javaHome: "/jdk21", javaExec: "/jdk21/bin/java", + errors: [], + errorCount: 0, + hints: [], }); }, javaDebugServer: (...a: unknown[]) => { diff --git a/src/features/debugger/java.ts b/src/features/debugger/java.ts index eed106d68..d8bfc44f2 100644 --- a/src/features/debugger/java.ts +++ b/src/features/debugger/java.ts @@ -6,7 +6,13 @@ */ import { executeAction } from "@/platform/actions"; import { call, commands, IpcError, type LaunchConfig, type RunConfig } from "@/platform/ipc"; -import { isJvmApplication, joinJavaArgs, parseJavaArgs, resolveJavaCommand } from "@/platform/javaLaunch"; +import { + formatCompileErrors, + isJvmApplication, + joinJavaArgs, + parseJavaArgs, + resolveJavaCommand, +} from "@/platform/javaLaunch"; import { notifications } from "@/workbench/contributions"; export const JAVA_ADAPTER = "java-debug"; @@ -52,6 +58,14 @@ export async function javaLaunchConfig(rc: RunConfig, root: string): Promise 0) + notifications().notify({ + group: "run", + level: "warning", + key: "debug.java-compile-errors", + title: `'${rc.name}' has compile errors; debugging the last compiled classes`, + body: formatCompileErrors(info).trimEnd(), + }); const resolved = resolveJavaCommand(rc, info); const port = await debugServer(module); const classPaths = parsed.explicitClasspath ? parsed.explicitClasspath.split(/[:;]/) : info.classPaths; diff --git a/src/features/run/java.test.ts b/src/features/run/java.test.ts index f62e2fb7b..2b8476ce0 100644 --- a/src/features/run/java.test.ts +++ b/src/features/run/java.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it } from "vitest"; import type { JavaLaunchInfo, RunConfig } from "@/platform/ipc"; import { + formatCompileErrors, joinJavaArgs, jdwpListeningPort, parseJavaArgs, @@ -170,6 +171,31 @@ describe("application configurations", () => { }); }); +describe("java compile errors", () => { + const base: Omit = { + build: "withErrors", + projectRoot: "/p", + classPaths: [], + modulePaths: [], + javaHome: null, + javaExec: null, + }; + + it("lists the first errors, counts the rest and adds hints", () => { + const report = formatCompileErrors({ + ...base, + errors: [{ path: "/p/src/A.java", line: 72, column: 5, message: "The method getUsername() is undefined" }], + errorCount: 3, + hints: ["Install Lombok"], + }); + expect(report).toBe( + "/p/src/A.java:72:5: error: The method getUsername() is undefined\n" + + "… and 2 more errors (see the Problems tool window)\nInstall Lombok\n", + ); + expect(formatCompileErrors({ ...base, errors: [], errorCount: 0, hints: [] })).toBe(""); + }); +}); + describe("java command lines", () => { const info: JavaLaunchInfo = { build: "succeeded", @@ -178,6 +204,9 @@ describe("java command lines", () => { modulePaths: [], javaHome: "/jdk21", javaExec: "/jdk21/bin/java", + errors: [], + errorCount: 0, + hints: [], }; it("parses VM options, main class and program arguments", () => { diff --git a/src/features/run/store.ts b/src/features/run/store.ts index ffdfcbc34..78484bf7a 100644 --- a/src/features/run/store.ts +++ b/src/features/run/store.ts @@ -6,7 +6,7 @@ import { create } from "zustand"; import { Channel } from "@tauri-apps/api/core"; import { call, commands, isTauri, type OutputChunk, type RunConfig, type RunId, type TestCaseReport } from "@/platform/ipc"; -import { isJvmApplication, parseJavaArgs, resolveJavaCommand } from "@/platform/javaLaunch"; +import { formatCompileErrors, isJvmApplication, parseJavaArgs, resolveJavaCommand } from "@/platform/javaLaunch"; import { useProject } from "@/platform/project"; import { workflowSettings } from "@/platform/workflowSettings"; import { maybeEditorService, notifications, toolWindowService } from "@/workbench/contributions"; @@ -449,6 +449,8 @@ async function resolveJvmApplication(sessionId: string, config: RunConfig): Prom const info = await call(commands.javaResolveLaunch(config.cwd ?? "", parsed.classpath === "test", true)); if (info.build === "withErrors") say("Build completed with errors; launching with the last compiled classes.\n", "stderr"); if (info.build === "failed") say("Build failed; launching with the last compiled classes.\n", "stderr"); + const compileReport = formatCompileErrors(info); + if (compileReport) say(compileReport, "stderr"); if (!info.classPaths.length) { say("The classpath is empty: is the module imported by the Java language server?\n", "stderr"); return null; diff --git a/src/features/settings/__tests__/languages.test.ts b/src/features/settings/__tests__/languages.test.ts index f5743cc6c..43c6ce689 100644 --- a/src/features/settings/__tests__/languages.test.ts +++ b/src/features/settings/__tests__/languages.test.ts @@ -21,12 +21,14 @@ describe("languages settings", () => { node: { packageManager: "pip" }, rust: { checkCommand: "clippy", checkOnSave: false }, typescript: { version: "" }, + java: { jdtlsVmArgs: " -Xmx2g " }, }); expect(v.python).toEqual({ interpreter: "/usr/bin/python3", activateVirtualenv: true }); expect(v.node.packageManager).toBe(""); expect(v.rust.checkCommand).toBe("clippy"); expect(v.rust.checkOnSave).toBe(false); expect(v.typescript.version).toBe("bundled"); + expect(v.java.jdtlsVmArgs).toBe("-Xmx2g"); expect(validateSettings({}).languages).toEqual(DEFAULT_LANGUAGES); }); @@ -48,7 +50,7 @@ describe("language pages", () => { const tree = buildPageTree(LANGUAGE_PAGES.map((p) => ({ ...p, component: p.component! }))); expect(tree.map((n) => n.title)).toEqual(["Project Structure", "Languages & Frameworks"]); const langs = tree.find((n) => n.title === "Languages & Frameworks")!; - expect(langs.children.map((n) => n.title)).toEqual(["Go", "Node.js", "Python Interpreter", "Rust", "TypeScript"]); + expect(langs.children.map((n) => n.title)).toEqual(["Go", "Java", "Node.js", "Python Interpreter", "Rust", "TypeScript"]); }); it("filters packages and shortens home paths", () => { diff --git a/src/features/settings/languages/LanguagePages.tsx b/src/features/settings/languages/LanguagePages.tsx index 27aca0589..832b91b8a 100644 --- a/src/features/settings/languages/LanguagePages.tsx +++ b/src/features/settings/languages/LanguagePages.tsx @@ -1,5 +1,5 @@ /** - * Settings › Languages & Frameworks › Go / Node.js / Rust / TypeScript: the + * Settings › Languages & Frameworks › Go / Java / Node.js / Rust / TypeScript: the * toolchain each language's server and run configurations use (applied by * `ide-sdk` when language servers start and when run configurations load). */ @@ -209,6 +209,34 @@ export function GoPage() { ); } +// --------------------------------------------------------------------------- +// Java + +export function JavaPage() { + const [vmArgs, setVmArgs] = useProjectSetting("languages.java.jdtlsVmArgs"); + const id = useId(); + return ( +
+
+ + setVmArgs(e.target.value)} + /> + +
+
+ ); +} + // --------------------------------------------------------------------------- // TypeScript diff --git a/src/features/settings/pages/languages.tsx b/src/features/settings/pages/languages.tsx index 547256eee..ab88dcc8d 100644 --- a/src/features/settings/pages/languages.tsx +++ b/src/features/settings/pages/languages.tsx @@ -1,9 +1,9 @@ /** * PyCharm's top-level "Python" group (Interpreter), "Project Structure", and - * Languages & Frameworks › Go / Node.js / Rust / TypeScript. Pages live in + * Languages & Frameworks › Go / Java / Node.js / Rust / TypeScript. Pages live in * `../languages/`; settings are `languages.*` / `projectStructure.*`. */ -import { GoPage, NodePage, RustPage, TypeScriptPage } from "../languages/LanguagePages"; +import { GoPage, JavaPage, NodePage, RustPage, TypeScriptPage } from "../languages/LanguagePages"; import { ProjectStructurePage } from "../languages/ProjectStructurePage"; import { PythonInterpreterPage } from "../languages/PythonInterpreterPage"; import type { PageSpec } from "./spec"; @@ -47,6 +47,14 @@ export const LANGUAGE_PAGES: PageSpec[] = [ keywords: ["goroot", "gopath", "go sdk", "gopls"], component: GoPage, }, + { + id: "languages.java", + title: "Java", + group: LANGUAGES_GROUP, + order: 395, + keywords: ["jdtls", "jvm options", "vmargs", "lombok", "javaagent", "eclipse jdt"], + component: JavaPage, + }, { id: "languages.node", title: "Node.js", diff --git a/src/features/tools/LanguageServersPage.tsx b/src/features/tools/LanguageServersPage.tsx index 7b838e6bb..d1bc29bf2 100644 --- a/src/features/tools/LanguageServersPage.tsx +++ b/src/features/tools/LanguageServersPage.tsx @@ -186,6 +186,7 @@ export function LanguageServersPage() {
{t.displayName} {t.kind === "debugger" && Debugger} + {t.kind === "plugin" && Plugin}
{(t.path ?? (t.command !== t.displayName ? t.command : null)) && (
diff --git a/src/platform/javaLaunch.ts b/src/platform/javaLaunch.ts index 58090a8d8..e2136366d 100644 --- a/src/platform/javaLaunch.ts +++ b/src/platform/javaLaunch.ts @@ -81,6 +81,15 @@ export function resolveJavaCommand(config: RunConfig, info: JavaLaunchInfo, path return { ...config, command, args }; } +/** Console report of a launch's compile errors (`path:line:col: error: …`) and their likely causes; "" when none. */ +export function formatCompileErrors(info: JavaLaunchInfo): string { + const lines = info.errors.map((e) => `${e.path}:${e.line}:${e.column}: error: ${e.message}`); + const more = info.errorCount - info.errors.length; + if (more > 0) lines.push(`… and ${more} more error${more === 1 ? "" : "s"} (see the Problems tool window)`); + lines.push(...info.hints); + return lines.length ? `${lines.join("\n")}\n` : ""; +} + /** Joins arguments into one string for adapters that take `args` / `vmArgs` as text (java-debug). */ export function joinJavaArgs(args: readonly string[]): string { return args.map((a) => (a === "" ? '""' : /[\s"]/.test(a) ? `"${a.replace(/(["\\])/g, "\\$1")}"` : a)).join(" "); diff --git a/src/platform/languageSettings.ts b/src/platform/languageSettings.ts index 89aefe0ea..895700669 100644 --- a/src/platform/languageSettings.ts +++ b/src/platform/languageSettings.ts @@ -11,6 +11,7 @@ export interface LanguagesValid { rust: { toolchain: string; stdlib: string; checkOnSave: boolean; checkCommand: "check" | "clippy"; rustfmtArgs: string }; go: { goroot: string; gopath: string }; typescript: { version: string }; + java: { jdtlsVmArgs: string }; } export interface ProjectStructureValid { @@ -29,6 +30,7 @@ export const DEFAULT_LANGUAGES: LanguagesValid = { rust: { toolchain: "", stdlib: "", checkOnSave: true, checkCommand: "check", rustfmtArgs: "" }, go: { goroot: "", gopath: "" }, typescript: { version: "bundled" }, + java: { jdtlsVmArgs: "" }, }; export const DEFAULT_PROJECT_STRUCTURE: ProjectStructureValid = { @@ -73,6 +75,7 @@ export function validateLanguages(raw: unknown): LanguagesValid { const rust = obj(l.rust); const go = obj(l.go); const ts = obj(l.typescript); + const java = obj(l.java); return { python: { interpreter: text(py.interpreter, d.python.interpreter), @@ -91,6 +94,7 @@ export function validateLanguages(raw: unknown): LanguagesValid { }, go: { goroot: text(go.goroot, d.go.goroot), gopath: text(go.gopath, d.go.gopath) }, typescript: { version: text(ts.version, d.typescript.version) || d.typescript.version }, + java: { jdtlsVmArgs: text(java.jdtlsVmArgs, d.java.jdtlsVmArgs) }, }; }