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..51517a2 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::{ @@ -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(worktree) - .map_err(|err| format!("Failed to determine JDTLS data path: {err}"))?; let mut args = vec![ path_to_string(java_executable)?, @@ -326,12 +321,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 +482,23 @@ 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> { + 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<()> { + args.push("-data".to_string()); + args.push(path_to_string(data_path)?); + Ok(()) +} + +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 @@ -524,10 +531,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 +678,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..b270580 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_default_jdtls_data_path, get_jdtls_launcher_from_path, + }, language_server::LanguageServer, proxy::Proxy, util::{path_to_file_uri, path_to_string}, @@ -52,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(); @@ -82,17 +87,22 @@ 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) = 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) @@ -100,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(), 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")); +}