Skip to content

Commit 7f807a4

Browse files
authored
Add custom data directory config (#309)
Let users define a custom data directory root for JDTLS cache Closes #303
1 parent 6d447a0 commit 7f807a4

6 files changed

Lines changed: 265 additions & 27 deletions

File tree

‎README.md‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,10 @@ Here is a common `settings.json` including the above mentioned configurations:
3939
"min_memory": "1G", // default: "1G"
4040
"max_memory": "2G", // default: unset (no -Xmx limit)
4141

42+
// Parent directory for JDTLS workspace data. The extension appends a
43+
// unique jdtls-<workspace-hash> directory for each worktree.
44+
"data_directory": "/path/to/jdtls-data",
45+
4246
// Controls when to check for updates for managed components
4347
// - "always" (default): Check for the latest version at most once every 24 hours
4448
// and reuse the last successfully resolved version between checks
@@ -60,6 +64,19 @@ Here is a common `settings.json` including the above mentioned configurations:
6064
}
6165
```
6266

67+
`data_directory` must be an absolute parent directory. For example,
68+
`"data_directory": "C:/Opt/zed-jdtls"` produces a workspace-specific path such
69+
as `C:/Opt/zed-jdtls/jdtls-<workspace-hash>`. The setting applies to both the
70+
extension-managed JDTLS and a JDTLS launcher selected through `jdtls_launcher`
71+
or `PATH`. An invalid configured value prevents JDTLS from starting instead of
72+
falling back to the default cache location. Changing it causes JDTLS to create
73+
a fresh workspace index; the old cache is not moved or deleted automatically.
74+
75+
The bundled **Clear default JDTLS cache** task only removes caches from the
76+
extension's default OS cache location. When `data_directory` is configured,
77+
delete the `jdtls-*` directories beneath that parent manually, then restart the
78+
language server.
79+
6380
## Gradle Build Files
6481

6582
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.

‎languages/java/tasks.json‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,8 +70,8 @@
7070
}
7171
},
7272
{
73-
"label": "Clear JDTLS cache",
74-
"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",
73+
"label": "Clear default JDTLS cache",
74+
"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.'",
7575
"use_new_terminal": false,
7676
"reveal": "always",
7777
"tags": [

‎src/config.rs‎

Lines changed: 136 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
use zed_extension_api::{Worktree, serde_json::Value};
1+
use std::path::Path;
2+
3+
use zed_extension_api::{Os, Worktree, current_platform, serde_json::Value};
24

35
use crate::util::expand_home_path;
46

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

42+
fn configured_jdtls_data_directory(configuration: &Option<Value>) -> Result<Option<&str>, String> {
43+
let Some(value) = configuration
44+
.as_ref()
45+
.and_then(|configuration| configuration.pointer("/data_directory"))
46+
else {
47+
return Ok(None);
48+
};
49+
50+
let path = value
51+
.as_str()
52+
.ok_or_else(|| "JDTLS data_directory must be a string".to_string())?;
53+
if path.trim().is_empty() {
54+
return Err("JDTLS data_directory must not be empty".to_string());
55+
}
56+
57+
Ok(Some(path))
58+
}
59+
60+
/// macOS and Linux rely on [`Path::is_absolute`].
61+
///
62+
/// Windows requires custom logic to recognize:
63+
/// - Drive paths: C:\... or C:/...
64+
/// - UNC paths: \\server\share\... or //server/share/..
65+
fn is_absolute_data_directory(path: &str, os: Os) -> bool {
66+
match os {
67+
Os::Windows => {
68+
let bytes = path.as_bytes();
69+
path.starts_with(r"\\")
70+
|| path.starts_with("//")
71+
|| (bytes.len() >= 3
72+
&& bytes[0].is_ascii_alphabetic()
73+
&& bytes[1] == b':'
74+
&& matches!(bytes[2], b'/' | b'\\'))
75+
}
76+
Os::Mac | Os::Linux => Path::new(path).is_absolute(),
77+
}
78+
}
79+
80+
fn validate_jdtls_data_directory(path: String, os: Os) -> Result<String, String> {
81+
if is_absolute_data_directory(&path, os) {
82+
Ok(path)
83+
} else {
84+
Err(format!(
85+
"JDTLS data_directory must be an absolute path: {path}"
86+
))
87+
}
88+
}
89+
90+
/// Returns the parent directory where per-worktree JDTLS data directories are stored.
91+
pub fn get_jdtls_data_directory(
92+
configuration: &Option<Value>,
93+
worktree: &Worktree,
94+
) -> Result<Option<String>, String> {
95+
let Some(data_directory) = configured_jdtls_data_directory(configuration)? else {
96+
return Ok(None);
97+
};
98+
let path = expand_home_path(worktree, data_directory.to_string())
99+
.map_err(|err| format!("Failed to expand JDTLS data_directory: {err}"))?;
100+
101+
validate_jdtls_data_directory(path, current_platform().0).map(Some)
102+
}
103+
40104
pub fn is_java_autodownload(configuration: &Option<Value>) -> bool {
41105
configuration
42106
.as_ref()
@@ -185,3 +249,74 @@ pub fn get_gradle_bridge_path(
185249

186250
None
187251
}
252+
253+
#[cfg(test)]
254+
mod tests {
255+
use zed_extension_api::serde_json::json;
256+
257+
use zed_extension_api::Os;
258+
259+
use super::{
260+
configured_jdtls_data_directory, is_absolute_data_directory, validate_jdtls_data_directory,
261+
};
262+
263+
#[test]
264+
fn configured_data_directory_distinguishes_absent_configuration() {
265+
assert_eq!(configured_jdtls_data_directory(&None), Ok(None));
266+
assert_eq!(configured_jdtls_data_directory(&Some(json!({}))), Ok(None));
267+
}
268+
269+
#[test]
270+
fn configured_data_directory_accepts_non_empty_strings() {
271+
let configuration = Some(json!({ "data_directory": "/tmp/jdtls" }));
272+
273+
assert_eq!(
274+
configured_jdtls_data_directory(&configuration),
275+
Ok(Some("/tmp/jdtls"))
276+
);
277+
}
278+
279+
#[test]
280+
fn configured_data_directory_rejects_empty_values() {
281+
let empty_error =
282+
configured_jdtls_data_directory(&Some(json!({ "data_directory": "" }))).unwrap_err();
283+
let whitespace_error =
284+
configured_jdtls_data_directory(&Some(json!({ "data_directory": " " }))).unwrap_err();
285+
286+
assert_eq!(empty_error, "JDTLS data_directory must not be empty");
287+
assert_eq!(whitespace_error, "JDTLS data_directory must not be empty");
288+
}
289+
290+
#[test]
291+
fn configured_data_directory_rejects_non_string_values() {
292+
let error =
293+
configured_jdtls_data_directory(&Some(json!({ "data_directory": true }))).unwrap_err();
294+
295+
assert_eq!(error, "JDTLS data_directory must be a string");
296+
}
297+
298+
#[test]
299+
fn data_directory_validation_rejects_relative_paths() {
300+
assert_eq!(
301+
validate_jdtls_data_directory("tmp/jdtls".to_string(), Os::Linux),
302+
Err("JDTLS data_directory must be an absolute path: tmp/jdtls".to_string())
303+
);
304+
assert_eq!(
305+
validate_jdtls_data_directory(r"Opt\zed-jdtls".to_string(), Os::Windows),
306+
Err(r"JDTLS data_directory must be an absolute path: Opt\zed-jdtls".to_string())
307+
);
308+
}
309+
310+
#[test]
311+
fn data_directory_requires_platform_absolute_paths() {
312+
assert!(is_absolute_data_directory("/tmp/jdtls", Os::Linux));
313+
assert!(!is_absolute_data_directory("tmp/jdtls", Os::Linux));
314+
assert!(is_absolute_data_directory(r"C:\Opt\zed-jdtls", Os::Windows));
315+
assert!(is_absolute_data_directory("C:/Opt/zed-jdtls", Os::Windows));
316+
assert!(is_absolute_data_directory(
317+
r"\\server\share\zed-jdtls",
318+
Os::Windows
319+
));
320+
assert!(!is_absolute_data_directory(r"Opt\zed-jdtls", Os::Windows));
321+
}
322+
}

‎src/jdtls.rs‎

Lines changed: 63 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ use zed_extension_api::{
1616
};
1717

1818
use crate::{
19-
config::{get_lombok_jar, is_java_autodownload},
19+
config::{get_jdtls_data_directory, get_lombok_jar, is_java_autodownload},
2020
downloadable::Downloadable,
2121
jdk::Jdk,
2222
util::{
@@ -248,17 +248,14 @@ fn parse_memory_value(s: &str) -> Option<u64> {
248248
}
249249

250250
pub fn build_jdtls_launch_args(
251-
jdtls_path: &PathBuf,
251+
jdtls_path: &Path,
252+
jdtls_data_path: &Path,
252253
configuration: &Option<Value>,
253254
worktree: &Worktree,
254255
jvm_args: Vec<String>,
255256
language_server_id: &LanguageServerId,
256257
jdk: &mut Jdk,
257258
) -> zed::Result<Vec<String>> {
258-
if let Some(jdtls_launcher) = get_jdtls_launcher_from_path(worktree) {
259-
return Ok(vec![jdtls_launcher]);
260-
}
261-
262259
let mut java_executable = get_java_executable(configuration, worktree, language_server_id)
263260
.map_err(|err| format!("Failed to locate Java executable for JDTLS: {err}"))?;
264261
let java_major_version = get_java_major_version(&java_executable)
@@ -283,8 +280,6 @@ pub fn build_jdtls_launch_args(
283280
let jar_path = find_equinox_launcher(&jdtls_base_path).map_err(|err| {
284281
format!("Failed to find JDTLS equinox launcher in {jdtls_base_path:?}: {err}")
285282
})?;
286-
let jdtls_data_path = get_jdtls_data_path(worktree)
287-
.map_err(|err| format!("Failed to determine JDTLS data path: {err}"))?;
288283

289284
let mut args = vec![
290285
path_to_string(java_executable)?,
@@ -326,12 +321,8 @@ pub fn build_jdtls_launch_args(
326321
"java.base/java.lang=ALL-UNNAMED".to_string(),
327322
]);
328323
args.extend(jvm_args);
329-
args.extend(vec![
330-
"-jar".to_string(),
331-
path_to_string(jar_path)?,
332-
"-data".to_string(),
333-
path_to_string(jdtls_data_path)?,
334-
]);
324+
args.extend(vec!["-jar".to_string(), path_to_string(jar_path)?]);
325+
append_jdtls_data_args(&mut args, jdtls_data_path)?;
335326
if java_major_version >= 24 {
336327
args.push("-Djdk.xml.maxGeneralEntitySizeLimit=0".to_string());
337328
args.push("-Djdk.xml.totalEntitySizeLimit=0".to_string());
@@ -491,7 +482,23 @@ fn find_equinox_launcher(jdtls_base_directory: &Path) -> Result<PathBuf, String>
491482
.ok_or_else(|| "Cannot find equinox launcher".to_string())
492483
}
493484

494-
fn get_jdtls_data_path(worktree: &Worktree) -> zed::Result<PathBuf> {
485+
pub fn get_configured_jdtls_data_path(
486+
configuration: &Option<Value>,
487+
worktree: &Worktree,
488+
) -> zed::Result<Option<PathBuf>> {
489+
let base_directory = get_jdtls_data_directory(configuration, worktree)?;
490+
Ok(base_directory.map(|base_directory| {
491+
build_jdtls_data_path(Path::new(&base_directory), &worktree.root_path())
492+
}))
493+
}
494+
495+
pub fn append_jdtls_data_args(args: &mut Vec<String>, data_path: &Path) -> zed::Result<()> {
496+
args.push("-data".to_string());
497+
args.push(path_to_string(data_path)?);
498+
Ok(())
499+
}
500+
501+
pub fn get_default_jdtls_data_path(worktree: &Worktree) -> zed::Result<PathBuf> {
495502
let env = worktree.shell_env();
496503
let base_cachedir = match current_platform().0 {
497504
Os::Mac => env
@@ -524,10 +531,13 @@ fn get_jdtls_data_path(worktree: &Worktree) -> zed::Result<PathBuf> {
524531
.map(|path| path.join("caches"))
525532
})?;
526533

527-
let cache_key = worktree.root_path();
528-
let hex_digest = get_sha1_hex(&cache_key);
534+
Ok(build_jdtls_data_path(&base_cachedir, &worktree.root_path()))
535+
}
536+
537+
fn build_jdtls_data_path(base_directory: &Path, cache_key: &str) -> PathBuf {
538+
let hex_digest = get_sha1_hex(cache_key);
529539
let unique_dir_name = format!("jdtls-{hex_digest}");
530-
Ok(base_cachedir.join(unique_dir_name))
540+
base_directory.join(unique_dir_name)
531541
}
532542

533543
fn get_binary_name() -> &'static str {
@@ -668,4 +678,39 @@ mod tests {
668678
assert!(staging.exists());
669679
let _ = fs::remove_dir_all(prefix);
670680
}
681+
682+
#[test]
683+
fn data_paths_are_stable_and_isolated_by_worktree() {
684+
let base_directory = Path::new("/tmp/custom-jdtls");
685+
let first = build_jdtls_data_path(base_directory, "/workspace/first");
686+
let first_again = build_jdtls_data_path(base_directory, "/workspace/first");
687+
let second = build_jdtls_data_path(base_directory, "/workspace/second");
688+
689+
assert_eq!(first, first_again);
690+
assert_ne!(first, second);
691+
assert_eq!(first.parent(), Some(base_directory));
692+
assert!(
693+
first
694+
.file_name()
695+
.and_then(|name| name.to_str())
696+
.is_some_and(|name| name.starts_with("jdtls-"))
697+
);
698+
}
699+
700+
#[test]
701+
fn data_path_arguments_are_appended_as_a_pair() {
702+
let mut args = vec!["jdtls".to_string()];
703+
let data_path = Path::new("/tmp/custom-jdtls/jdtls-workspace");
704+
705+
append_jdtls_data_args(&mut args, data_path).unwrap();
706+
707+
assert_eq!(
708+
args,
709+
vec![
710+
"jdtls".to_string(),
711+
"-data".to_string(),
712+
data_path.to_string_lossy().to_string()
713+
]
714+
);
715+
}
671716
}

‎src/jdtls_server.rs‎

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,10 @@ use crate::{
1212
debugger::Debugger,
1313
downloadable::Downloadable,
1414
jdk::Jdk,
15-
jdtls::{Jdtls, Lombok, build_jdtls_launch_args, get_jdtls_launcher_from_path},
15+
jdtls::{
16+
Jdtls, Lombok, append_jdtls_data_args, build_jdtls_launch_args,
17+
get_configured_jdtls_data_path, get_default_jdtls_data_path, get_jdtls_launcher_from_path,
18+
},
1619
language_server::LanguageServer,
1720
proxy::Proxy,
1821
util::{path_to_file_uri, path_to_string},
@@ -52,6 +55,8 @@ impl LanguageServer for JdtlsServer {
5255
env::current_dir().map_err(|err| format!("Failed to get current directory: {err}"))?;
5356

5457
let configuration = self.workspace_configuration(language_server_id, worktree)?;
58+
let configured_data_path = get_configured_jdtls_data_path(&configuration, worktree)
59+
.map_err(|err| format!("Failed to determine JDTLS data path: {err}"))?;
5560

5661
let mut env = Vec::new();
5762

@@ -82,24 +87,30 @@ impl LanguageServer for JdtlsServer {
8287
None
8388
};
8489

85-
if let Some(launcher) = get_jdtls_launcher(&configuration, worktree) {
90+
let configured_launcher = get_jdtls_launcher(&configuration, worktree)
91+
.or_else(|| get_jdtls_launcher_from_path(worktree));
92+
if let Some(launcher) = configured_launcher {
8693
args.push(launcher);
8794
if let Some(lombok_jvm_arg) = lombok_jvm_arg {
8895
args.push(format!("--jvm-arg={lombok_jvm_arg}"));
8996
}
90-
} else if let Some(launcher) = get_jdtls_launcher_from_path(worktree) {
91-
args.push(launcher);
92-
if let Some(lombok_jvm_arg) = lombok_jvm_arg {
93-
args.push(format!("--jvm-arg={lombok_jvm_arg}"));
97+
if let Some(data_path) = configured_data_path.as_deref() {
98+
append_jdtls_data_args(&mut args, data_path)?;
9499
}
95100
} else {
101+
let data_path = match configured_data_path {
102+
Some(data_path) => data_path,
103+
None => get_default_jdtls_data_path(worktree)
104+
.map_err(|err| format!("Failed to determine JDTLS data path: {err}"))?,
105+
};
96106
let jdtls_path = self
97107
.jdtls
98108
.get_or_download(language_server_id, &configuration, worktree)
99109
.map_err(|err| format!("Failed to get JDTLS binary path: {err}"))?;
100110
args.extend(
101111
build_jdtls_launch_args(
102112
&jdtls_path,
113+
&data_path,
103114
&configuration,
104115
worktree,
105116
lombok_jvm_arg.into_iter().collect(),

0 commit comments

Comments
 (0)