From e67f3114827ebe32a7e7a47f0e09f4996038b450 Mon Sep 17 00:00:00 2001 From: Riccardo Strina Date: Sat, 12 Sep 2026 13:16:21 +0100 Subject: [PATCH 1/2] Add configurable JDTLS data directory Introduce a `data_directory` setting to control where per-worktree JDTLS workspace data is stored. The setting requires an absolute path, applies to both extension-managed and PATH/launcher-based JDTLS, and falls back to the OS cache location when unset. Rename the bundled task to "Clear default JDTLS cache" to clarify that it only removes caches from the default location. --- README.md | 17 ++++ languages/java/tasks.json | 4 +- src/config.rs | 137 +++++++++++++++++++++++++++++++- src/jdtls.rs | 79 +++++++++++++++--- src/jdtls_server.rs | 17 ++-- tests/task_verification_test.rs | 30 +++++++ 6 files changed, 263 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index fd3c2f8..4323eed 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,10 @@ Here is a common `settings.json` including the above mentioned configurations: "min_memory": "1G", // default: "1G" "max_memory": "2G", // default: unset (no -Xmx limit) + // Parent directory for JDTLS workspace data. The extension appends a + // unique jdtls- directory for each worktree. + "data_directory": "/path/to/jdtls-data", + // Controls when to check for updates for managed components // - "always" (default): Check for the latest version at most once every 24 hours // and reuse the last successfully resolved version between checks @@ -60,6 +64,19 @@ Here is a common `settings.json` including the above mentioned configurations: } ``` +`data_directory` must be an absolute parent directory. For example, +`"data_directory": "C:/Opt/zed-jdtls"` produces a workspace-specific path such +as `C:/Opt/zed-jdtls/jdtls-`. The setting applies to both the +extension-managed JDTLS and a JDTLS launcher selected through `jdtls_launcher` +or `PATH`. An invalid configured value prevents JDTLS from starting instead of +falling back to the default cache location. Changing it causes JDTLS to create +a fresh workspace index; the old cache is not moved or deleted automatically. + +The bundled **Clear default JDTLS cache** task only removes caches from the +extension's default OS cache location. When `data_directory` is configured, +delete the `jdtls-*` directories beneath that parent manually, then restart the +language server. + ## Gradle Build Files For **Groovy** build scripts (`.gradle`) the extension runs Microsoft's [Gradle Language Server](https://github.com/microsoft/vscode-gradle), giving you completions for Gradle DSL closures, plugin-contributed blocks (e.g. `java {}`, `application {}`), Maven Central dependency coordinates, and syntax diagnostics. diff --git a/languages/java/tasks.json b/languages/java/tasks.json index 6c8d307..4f757f9 100644 --- a/languages/java/tasks.json +++ b/languages/java/tasks.json @@ -70,8 +70,8 @@ } }, { - "label": "Clear JDTLS cache", - "command": "cache_dir=\"\"; if [ -n \"$XDG_CACHE_HOME\" ]; then cache_dir=\"$XDG_CACHE_HOME\"; elif [ \"$(uname)\" = \"Darwin\" ]; then cache_dir=\"$HOME/Library/Caches\"; else cache_dir=\"$HOME/.cache\"; fi; found=$(find \"$cache_dir\" -maxdepth 1 -type d -name 'jdtls-*' 2>/dev/null); if [ -n \"$found\" ]; then echo \"$found\" | xargs rm -rf && echo 'JDTLS cache cleared. Restart the language server'; else echo 'No JDTLS cache found'; fi", + "label": "Clear default JDTLS cache", + "command": "cache_dir=\"\"; if [ -n \"$XDG_CACHE_HOME\" ]; then cache_dir=\"$XDG_CACHE_HOME\"; elif [ \"$(uname)\" = \"Darwin\" ]; then cache_dir=\"$HOME/Library/Caches\"; else cache_dir=\"$HOME/.cache\"; fi; found=$(find \"$cache_dir\" -maxdepth 1 -type d -name 'jdtls-*' 2>/dev/null); if [ -n \"$found\" ]; then echo \"$found\" | xargs rm -rf && echo 'Default JDTLS cache cleared. Restart the language server'; else echo 'No default JDTLS cache found'; fi; echo 'If data_directory is configured, delete its jdtls-* directories manually.'", "use_new_terminal": false, "reveal": "always", "tags": [ diff --git a/src/config.rs b/src/config.rs index cd4f730..f07f060 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,4 +1,6 @@ -use zed_extension_api::{Worktree, serde_json::Value}; +use std::path::Path; + +use zed_extension_api::{Os, Worktree, current_platform, serde_json::Value}; use crate::util::expand_home_path; @@ -37,6 +39,68 @@ pub fn get_java_home(configuration: &Option, worktree: &Worktree) -> Opti } } +fn configured_jdtls_data_directory(configuration: &Option) -> Result, String> { + let Some(value) = configuration + .as_ref() + .and_then(|configuration| configuration.pointer("/data_directory")) + else { + return Ok(None); + }; + + let path = value + .as_str() + .ok_or_else(|| "JDTLS data_directory must be a string".to_string())?; + if path.trim().is_empty() { + return Err("JDTLS data_directory must not be empty".to_string()); + } + + Ok(Some(path)) +} + +/// macOS and Linux rely on [`Path::is_absolute`]. +/// +/// Windows requires custom logic to recognize: +/// - Drive paths: C:\... or C:/... +/// - UNC paths: \\server\share\... or //server/share/.. +fn is_absolute_data_directory(path: &str, os: Os) -> bool { + match os { + Os::Windows => { + let bytes = path.as_bytes(); + path.starts_with(r"\\") + || path.starts_with("//") + || (bytes.len() >= 3 + && bytes[0].is_ascii_alphabetic() + && bytes[1] == b':' + && matches!(bytes[2], b'/' | b'\\')) + } + Os::Mac | Os::Linux => Path::new(path).is_absolute(), + } +} + +fn validate_jdtls_data_directory(path: String, os: Os) -> Result { + if is_absolute_data_directory(&path, os) { + Ok(path) + } else { + Err(format!( + "JDTLS data_directory must be an absolute path: {path}" + )) + } +} + +/// Returns the parent directory where per-worktree JDTLS data directories are stored. +pub fn get_jdtls_data_directory( + configuration: &Option, + worktree: &Worktree, +) -> Result, String> { + let Some(data_directory) = configured_jdtls_data_directory(configuration)? else { + return Ok(None); + }; + let path = expand_home_path(worktree, data_directory.to_string()) + .map_err(|err| format!("Failed to expand JDTLS data_directory: {err}"))?; + + validate_jdtls_data_directory(path, current_platform().0).map(Some) +} + pub fn is_java_autodownload(configuration: &Option) -> bool { configuration .as_ref() @@ -185,3 +249,74 @@ pub fn get_gradle_bridge_path( None } + +#[cfg(test)] +mod tests { + use zed_extension_api::serde_json::json; + + use zed_extension_api::Os; + + use super::{ + configured_jdtls_data_directory, is_absolute_data_directory, validate_jdtls_data_directory, + }; + + #[test] + fn configured_data_directory_distinguishes_absent_configuration() { + assert_eq!(configured_jdtls_data_directory(&None), Ok(None)); + assert_eq!(configured_jdtls_data_directory(&Some(json!({}))), Ok(None)); + } + + #[test] + fn configured_data_directory_accepts_non_empty_strings() { + let configuration = Some(json!({ "data_directory": "/tmp/jdtls" })); + + assert_eq!( + configured_jdtls_data_directory(&configuration), + Ok(Some("/tmp/jdtls")) + ); + } + + #[test] + fn configured_data_directory_rejects_empty_values() { + let empty_error = + configured_jdtls_data_directory(&Some(json!({ "data_directory": "" }))).unwrap_err(); + let whitespace_error = + configured_jdtls_data_directory(&Some(json!({ "data_directory": " " }))).unwrap_err(); + + assert_eq!(empty_error, "JDTLS data_directory must not be empty"); + assert_eq!(whitespace_error, "JDTLS data_directory must not be empty"); + } + + #[test] + fn configured_data_directory_rejects_non_string_values() { + let error = + configured_jdtls_data_directory(&Some(json!({ "data_directory": true }))).unwrap_err(); + + assert_eq!(error, "JDTLS data_directory must be a string"); + } + + #[test] + fn data_directory_validation_rejects_relative_paths() { + assert_eq!( + validate_jdtls_data_directory("tmp/jdtls".to_string(), Os::Linux), + Err("JDTLS data_directory must be an absolute path: tmp/jdtls".to_string()) + ); + assert_eq!( + validate_jdtls_data_directory(r"Opt\zed-jdtls".to_string(), Os::Windows), + Err(r"JDTLS data_directory must be an absolute path: Opt\zed-jdtls".to_string()) + ); + } + + #[test] + fn data_directory_requires_platform_absolute_paths() { + assert!(is_absolute_data_directory("/tmp/jdtls", Os::Linux)); + assert!(!is_absolute_data_directory("tmp/jdtls", Os::Linux)); + assert!(is_absolute_data_directory(r"C:\Opt\zed-jdtls", Os::Windows)); + assert!(is_absolute_data_directory("C:/Opt/zed-jdtls", Os::Windows)); + assert!(is_absolute_data_directory( + r"\\server\share\zed-jdtls", + Os::Windows + )); + assert!(!is_absolute_data_directory(r"Opt\zed-jdtls", Os::Windows)); + } +} diff --git a/src/jdtls.rs b/src/jdtls.rs index 49a517c..de09c93 100644 --- a/src/jdtls.rs +++ b/src/jdtls.rs @@ -16,7 +16,7 @@ use zed_extension_api::{ }; use crate::{ - config::{get_lombok_jar, is_java_autodownload}, + config::{get_jdtls_data_directory, get_lombok_jar, is_java_autodownload}, downloadable::Downloadable, jdk::Jdk, util::{ @@ -283,7 +283,7 @@ pub fn build_jdtls_launch_args( let jar_path = find_equinox_launcher(&jdtls_base_path).map_err(|err| { format!("Failed to find JDTLS equinox launcher in {jdtls_base_path:?}: {err}") })?; - let jdtls_data_path = get_jdtls_data_path(worktree) + let jdtls_data_path = get_jdtls_data_path(configuration, worktree) .map_err(|err| format!("Failed to determine JDTLS data path: {err}"))?; let mut args = vec![ @@ -326,12 +326,8 @@ pub fn build_jdtls_launch_args( "java.base/java.lang=ALL-UNNAMED".to_string(), ]); args.extend(jvm_args); - args.extend(vec![ - "-jar".to_string(), - path_to_string(jar_path)?, - "-data".to_string(), - path_to_string(jdtls_data_path)?, - ]); + args.extend(vec!["-jar".to_string(), path_to_string(jar_path)?]); + append_jdtls_data_args(&mut args, &jdtls_data_path)?; if java_major_version >= 24 { args.push("-Djdk.xml.maxGeneralEntitySizeLimit=0".to_string()); args.push("-Djdk.xml.totalEntitySizeLimit=0".to_string()); @@ -491,7 +487,28 @@ fn find_equinox_launcher(jdtls_base_directory: &Path) -> Result .ok_or_else(|| "Cannot find equinox launcher".to_string()) } -fn get_jdtls_data_path(worktree: &Worktree) -> zed::Result { +pub fn get_configured_jdtls_data_path( + configuration: &Option, + worktree: &Worktree, +) -> zed::Result> { + Ok( + get_jdtls_data_directory(configuration, worktree)?.map(|base_directory| { + build_jdtls_data_path(Path::new(&base_directory), &worktree.root_path()) + }), + ) +} + +pub fn append_jdtls_data_args(args: &mut Vec, data_path: &Path) -> zed::Result<()> { + args.push("-data".to_string()); + args.push(path_to_string(data_path)?); + Ok(()) +} + +fn get_jdtls_data_path(configuration: &Option, worktree: &Worktree) -> zed::Result { + if let Some(data_path) = get_configured_jdtls_data_path(configuration, worktree)? { + return Ok(data_path); + } + let env = worktree.shell_env(); let base_cachedir = match current_platform().0 { Os::Mac => env @@ -524,10 +541,13 @@ fn get_jdtls_data_path(worktree: &Worktree) -> zed::Result { .map(|path| path.join("caches")) })?; - let cache_key = worktree.root_path(); - let hex_digest = get_sha1_hex(&cache_key); + Ok(build_jdtls_data_path(&base_cachedir, &worktree.root_path())) +} + +fn build_jdtls_data_path(base_directory: &Path, cache_key: &str) -> PathBuf { + let hex_digest = get_sha1_hex(cache_key); let unique_dir_name = format!("jdtls-{hex_digest}"); - Ok(base_cachedir.join(unique_dir_name)) + base_directory.join(unique_dir_name) } fn get_binary_name() -> &'static str { @@ -668,4 +688,39 @@ mod tests { assert!(staging.exists()); let _ = fs::remove_dir_all(prefix); } + + #[test] + fn data_paths_are_stable_and_isolated_by_worktree() { + let base_directory = Path::new("/tmp/custom-jdtls"); + let first = build_jdtls_data_path(base_directory, "/workspace/first"); + let first_again = build_jdtls_data_path(base_directory, "/workspace/first"); + let second = build_jdtls_data_path(base_directory, "/workspace/second"); + + assert_eq!(first, first_again); + assert_ne!(first, second); + assert_eq!(first.parent(), Some(base_directory)); + assert!( + first + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.starts_with("jdtls-")) + ); + } + + #[test] + fn data_path_arguments_are_appended_as_a_pair() { + let mut args = vec!["jdtls".to_string()]; + let data_path = Path::new("/tmp/custom-jdtls/jdtls-workspace"); + + append_jdtls_data_args(&mut args, data_path).unwrap(); + + assert_eq!( + args, + vec![ + "jdtls".to_string(), + "-data".to_string(), + data_path.to_string_lossy().to_string() + ] + ); + } } diff --git a/src/jdtls_server.rs b/src/jdtls_server.rs index f5558f6..16fcaf8 100644 --- a/src/jdtls_server.rs +++ b/src/jdtls_server.rs @@ -12,7 +12,10 @@ use crate::{ debugger::Debugger, downloadable::Downloadable, jdk::Jdk, - jdtls::{Jdtls, Lombok, build_jdtls_launch_args, get_jdtls_launcher_from_path}, + jdtls::{ + Jdtls, Lombok, append_jdtls_data_args, build_jdtls_launch_args, + get_configured_jdtls_data_path, get_jdtls_launcher_from_path, + }, language_server::LanguageServer, proxy::Proxy, util::{path_to_file_uri, path_to_string}, @@ -82,15 +85,17 @@ impl LanguageServer for JdtlsServer { None }; - if let Some(launcher) = get_jdtls_launcher(&configuration, worktree) { + let configured_launcher = get_jdtls_launcher(&configuration, worktree) + .or_else(|| get_jdtls_launcher_from_path(worktree)); + if let Some(launcher) = configured_launcher { args.push(launcher); if let Some(lombok_jvm_arg) = lombok_jvm_arg { args.push(format!("--jvm-arg={lombok_jvm_arg}")); } - } else if let Some(launcher) = get_jdtls_launcher_from_path(worktree) { - args.push(launcher); - if let Some(lombok_jvm_arg) = lombok_jvm_arg { - args.push(format!("--jvm-arg={lombok_jvm_arg}")); + if let Some(data_path) = get_configured_jdtls_data_path(&configuration, worktree) + .map_err(|err| format!("Failed to determine JDTLS data path: {err}"))? + { + append_jdtls_data_args(&mut args, &data_path)?; } } else { let jdtls_path = self diff --git a/tests/task_verification_test.rs b/tests/task_verification_test.rs index 3588ceb..cb70cd3 100644 --- a/tests/task_verification_test.rs +++ b/tests/task_verification_test.rs @@ -162,6 +162,10 @@ impl<'a> TaskRunner<'a> { .push(("ZED_CUSTOM_java_method_name", m.to_string())); self } + fn env(mut self, key: &'static str, value: String) -> Self { + self.extra_env.push((key, value)); + self + } fn outer_class(mut self, o: &str) -> Self { self.extra_env .push(("ZED_CUSTOM_java_outer_class_name", o.to_string())); @@ -704,3 +708,29 @@ fn test_no_build_tool_command_logic() { stdout ); } + +#[test] +fn test_clear_cache_task_only_removes_default_jdtls_directories() { + let project = TestProject::new("clear_jdtls_cache", "none", None); + let default_cache = project.temp_dir.join("default-cache"); + let default_jdtls = default_cache.join("jdtls-default"); + let unrelated = default_cache.join("unrelated"); + let custom_jdtls = project.temp_dir.join("custom-cache").join("jdtls-custom"); + fs::create_dir_all(&default_jdtls).unwrap(); + fs::create_dir_all(&unrelated).unwrap(); + fs::create_dir_all(&custom_jdtls).unwrap(); + + let stdout = project + .task("java-clear-cache") + .env( + "XDG_CACHE_HOME", + default_cache.to_string_lossy().to_string(), + ) + .run(); + + assert!(!default_jdtls.exists()); + assert!(unrelated.exists()); + assert!(custom_jdtls.exists()); + assert!(stdout.contains("Default JDTLS cache cleared")); + assert!(stdout.contains("data_directory")); +} From 7b35645055c9d62aa2c0de1f1b40cb55802c902d Mon Sep 17 00:00:00 2001 From: Riccardo Strina Date: Sat, 12 Sep 2026 20:35:50 +0100 Subject: [PATCH 2/2] Simplify retrieval of custom data directory --- src/jdtls.rs | 26 ++++++++------------------ src/jdtls_server.rs | 16 +++++++++++----- 2 files changed, 19 insertions(+), 23 deletions(-) diff --git a/src/jdtls.rs b/src/jdtls.rs index de09c93..51517a2 100644 --- a/src/jdtls.rs +++ b/src/jdtls.rs @@ -248,17 +248,14 @@ fn parse_memory_value(s: &str) -> Option { } pub fn build_jdtls_launch_args( - jdtls_path: &PathBuf, + jdtls_path: &Path, + jdtls_data_path: &Path, configuration: &Option, worktree: &Worktree, jvm_args: Vec, language_server_id: &LanguageServerId, jdk: &mut Jdk, ) -> zed::Result> { - if let Some(jdtls_launcher) = get_jdtls_launcher_from_path(worktree) { - return Ok(vec![jdtls_launcher]); - } - let mut java_executable = get_java_executable(configuration, worktree, language_server_id) .map_err(|err| format!("Failed to locate Java executable for JDTLS: {err}"))?; let java_major_version = get_java_major_version(&java_executable) @@ -283,8 +280,6 @@ pub fn build_jdtls_launch_args( let jar_path = find_equinox_launcher(&jdtls_base_path).map_err(|err| { format!("Failed to find JDTLS equinox launcher in {jdtls_base_path:?}: {err}") })?; - let jdtls_data_path = get_jdtls_data_path(configuration, worktree) - .map_err(|err| format!("Failed to determine JDTLS data path: {err}"))?; let mut args = vec![ path_to_string(java_executable)?, @@ -327,7 +322,7 @@ pub fn build_jdtls_launch_args( ]); args.extend(jvm_args); args.extend(vec!["-jar".to_string(), path_to_string(jar_path)?]); - append_jdtls_data_args(&mut args, &jdtls_data_path)?; + append_jdtls_data_args(&mut args, jdtls_data_path)?; if java_major_version >= 24 { args.push("-Djdk.xml.maxGeneralEntitySizeLimit=0".to_string()); args.push("-Djdk.xml.totalEntitySizeLimit=0".to_string()); @@ -491,11 +486,10 @@ pub fn get_configured_jdtls_data_path( configuration: &Option, worktree: &Worktree, ) -> zed::Result> { - Ok( - get_jdtls_data_directory(configuration, worktree)?.map(|base_directory| { - build_jdtls_data_path(Path::new(&base_directory), &worktree.root_path()) - }), - ) + let base_directory = get_jdtls_data_directory(configuration, worktree)?; + Ok(base_directory.map(|base_directory| { + build_jdtls_data_path(Path::new(&base_directory), &worktree.root_path()) + })) } pub fn append_jdtls_data_args(args: &mut Vec, data_path: &Path) -> zed::Result<()> { @@ -504,11 +498,7 @@ pub fn append_jdtls_data_args(args: &mut Vec, data_path: &Path) -> zed:: Ok(()) } -fn get_jdtls_data_path(configuration: &Option, worktree: &Worktree) -> zed::Result { - if let Some(data_path) = get_configured_jdtls_data_path(configuration, worktree)? { - return Ok(data_path); - } - +pub fn get_default_jdtls_data_path(worktree: &Worktree) -> zed::Result { let env = worktree.shell_env(); let base_cachedir = match current_platform().0 { Os::Mac => env diff --git a/src/jdtls_server.rs b/src/jdtls_server.rs index 16fcaf8..b270580 100644 --- a/src/jdtls_server.rs +++ b/src/jdtls_server.rs @@ -14,7 +14,7 @@ use crate::{ jdk::Jdk, jdtls::{ Jdtls, Lombok, append_jdtls_data_args, build_jdtls_launch_args, - get_configured_jdtls_data_path, get_jdtls_launcher_from_path, + get_configured_jdtls_data_path, get_default_jdtls_data_path, get_jdtls_launcher_from_path, }, language_server::LanguageServer, proxy::Proxy, @@ -55,6 +55,8 @@ impl LanguageServer for JdtlsServer { env::current_dir().map_err(|err| format!("Failed to get current directory: {err}"))?; let configuration = self.workspace_configuration(language_server_id, worktree)?; + let configured_data_path = get_configured_jdtls_data_path(&configuration, worktree) + .map_err(|err| format!("Failed to determine JDTLS data path: {err}"))?; let mut env = Vec::new(); @@ -92,12 +94,15 @@ impl LanguageServer for JdtlsServer { if let Some(lombok_jvm_arg) = lombok_jvm_arg { args.push(format!("--jvm-arg={lombok_jvm_arg}")); } - if let Some(data_path) = get_configured_jdtls_data_path(&configuration, worktree) - .map_err(|err| format!("Failed to determine JDTLS data path: {err}"))? - { - append_jdtls_data_args(&mut args, &data_path)?; + if let Some(data_path) = configured_data_path.as_deref() { + append_jdtls_data_args(&mut args, data_path)?; } } else { + let data_path = match configured_data_path { + Some(data_path) => data_path, + None => get_default_jdtls_data_path(worktree) + .map_err(|err| format!("Failed to determine JDTLS data path: {err}"))?, + }; let jdtls_path = self .jdtls .get_or_download(language_server_id, &configuration, worktree) @@ -105,6 +110,7 @@ impl LanguageServer for JdtlsServer { args.extend( build_jdtls_launch_args( &jdtls_path, + &data_path, &configuration, worktree, lombok_jvm_arg.into_iter().collect(),