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) }, }; }