Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<workspace-hash> 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
Expand All @@ -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-<workspace-hash>`. 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.
Expand Down
4 changes: 2 additions & 2 deletions languages/java/tasks.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down
137 changes: 136 additions & 1 deletion src/config.rs
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -37,6 +39,68 @@ pub fn get_java_home(configuration: &Option<Value>, worktree: &Worktree) -> Opti
}
}

fn configured_jdtls_data_directory(configuration: &Option<Value>) -> Result<Option<&str>, 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<String, String> {
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<Value>,
worktree: &Worktree,
) -> Result<Option<String>, 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<Value>) -> bool {
configuration
.as_ref()
Expand Down Expand Up @@ -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));
}
}
81 changes: 63 additions & 18 deletions src/jdtls.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::{
Expand Down Expand Up @@ -248,17 +248,14 @@ fn parse_memory_value(s: &str) -> Option<u64> {
}

pub fn build_jdtls_launch_args(
jdtls_path: &PathBuf,
jdtls_path: &Path,
jdtls_data_path: &Path,
configuration: &Option<Value>,
worktree: &Worktree,
jvm_args: Vec<String>,
language_server_id: &LanguageServerId,
jdk: &mut Jdk,
) -> zed::Result<Vec<String>> {
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)
Expand All @@ -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)?,
Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -491,7 +482,23 @@ fn find_equinox_launcher(jdtls_base_directory: &Path) -> Result<PathBuf, String>
.ok_or_else(|| "Cannot find equinox launcher".to_string())
}

fn get_jdtls_data_path(worktree: &Worktree) -> zed::Result<PathBuf> {
pub fn get_configured_jdtls_data_path(
configuration: &Option<Value>,
worktree: &Worktree,
) -> zed::Result<Option<PathBuf>> {
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<String>, 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<PathBuf> {
let env = worktree.shell_env();
let base_cachedir = match current_platform().0 {
Os::Mac => env
Expand Down Expand Up @@ -524,10 +531,13 @@ fn get_jdtls_data_path(worktree: &Worktree) -> zed::Result<PathBuf> {
.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 {
Expand Down Expand Up @@ -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()
]
);
}
}
23 changes: 17 additions & 6 deletions src/jdtls_server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -82,24 +87,30 @@ 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)
.map_err(|err| format!("Failed to get JDTLS binary path: {err}"))?;
args.extend(
build_jdtls_launch_args(
&jdtls_path,
&data_path,
&configuration,
worktree,
lombok_jvm_arg.into_iter().collect(),
Expand Down
Loading