Skip to content
Merged
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
11 changes: 8 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,9 @@ Here is a common `settings.json` including the above mentioned configurations:
"min_memory": "1G", // default: "1G"
"max_memory": "2G", // default: unset (no -Xmx limit)

// Controls when to check for updates for JDTLS, Lombok, and Debugger
// - "always" (default): Always check for and download the latest version
// 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
// - "once": Check for updates only if no local installation exists
// - "never": Never check for updates, only use existing local installations (errors if missing)
//
Expand Down Expand Up @@ -96,6 +97,10 @@ Configuration, when you need it, goes under the `gradle-language-server` languag
"gradleUserHome": null, // overrides GRADLE_USER_HOME
"gradle_jvm_arguments": null, // e.g. "-Xmx2G" for the Gradle build

// Uses the same always/once/never policy as JDTLS. In "always" mode,
// successful version checks are cached for 24 hours.
"check_updates": "always",

// Path to a locally built gradle-lsp-bridge binary, overriding the
// managed download. Primarily for development (see Developing Locally).
"gradle_bridge_path": "/path/to/your/gradle-lsp-bridge"
Expand Down Expand Up @@ -623,7 +628,7 @@ If changes are not picked up, clean JDTLS' cache (from a java file run the task

## Architecture Note

The extension uses two native binaries, both automatically downloaded from the [extension repository releases](https://github.com/zed-extensions/java/releases) and requiring no user configuration:
The extension uses two native binaries, both automatically downloaded from the [extension repository releases](https://github.com/zed-extensions/java/releases) and requiring no user configuration. Their managed versions are always bound to the extension version, so they do not use the 24-hour version cache and are not affected by `check_updates`. Explicit path settings and root-level development binaries installed with the `*-install` recipes still override the managed binaries:

- **`java-lsp-proxy`** wraps the JDTLS process, enabling the extension to communicate with JDTLS for features like debug class resolution and classpath queries.
- **`gradle-lsp-bridge`** bridges Zed to the Gradle Language Server and drives the bundled `gradle-server` over gRPC to supply the resolved build model (see [Gradle Build Files](#gradle-build-files)). It pulls in an async/gRPC stack, so it is kept as a separate binary from the deliberately lean JDTLS proxy.
Expand Down
31 changes: 22 additions & 9 deletions src/debugger.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ use crate::{
downloadable::Downloadable,
lsp,
util::{
ArgsStringOrList, create_path_if_not_exists, get_curr_dir, mark_checked_once,
path_to_string, should_use_local_or_download,
ArgsStringOrList, create_path_if_not_exists, get_curr_dir, path_to_string,
record_successful_update_check, should_use_local_or_download, update_check_path,
},
};

Expand Down Expand Up @@ -114,6 +114,11 @@ impl Debugger {
return Ok(path.clone());
}

if fs::metadata(&jar_path).is_ok_and(|stat| stat.is_file()) {
self.plugin_path = Some(jar_path.clone());
return Ok(jar_path);
}

create_path_if_not_exists(prefix)
.map_err(|err| format!("Failed to create debugger directory '{prefix}': {err}"))?;

Expand All @@ -127,8 +132,10 @@ impl Debugger {
format!("Failed to download java-debug fork from {JAVA_DEBUG_PLUGIN_FORK_URL}: {err}")
})?;

// Mark the downloaded version for "Once" mode tracking
let _ = mark_checked_once(DEBUGGER_INSTALL_PATH, latest_version);
let _ = record_successful_update_check(
&update_check_path(DEBUGGER_INSTALL_PATH),
latest_version,
);

self.plugin_path = Some(jar_path.clone());
Ok(jar_path)
Expand Down Expand Up @@ -234,8 +241,10 @@ impl Debugger {
)
.map_err(|err| format!("Failed to download {url}: {err}"))?;

// Mark the downloaded version for "Once" mode tracking
let _ = mark_checked_once(DEBUGGER_INSTALL_PATH, latest_version);
let _ = record_successful_update_check(
&update_check_path(DEBUGGER_INSTALL_PATH),
latest_version,
);
}

self.plugin_path = Some(jar_path.clone());
Expand Down Expand Up @@ -445,9 +454,13 @@ impl Downloadable for Debugger {
return Ok(path);
}

if let Some(path) =
should_use_local_or_download(configuration, self.find_local(), Self::INSTALL_PATH)
.map_err(|err| format!("Failed to resolve debugger installation: {err}"))?
if let Some(path) = should_use_local_or_download(
configuration,
self.find_local(),
Self::INSTALL_PATH,
&self.update_check_path(),
)
.map_err(|err| format!("Failed to resolve debugger installation: {err}"))?
{
self.plugin_path = Some(path.clone());
return Ok(path);
Expand Down
113 changes: 98 additions & 15 deletions src/downloadable.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,17 @@
use std::path::PathBuf;

use zed_extension_api::{self as zed, LanguageServerId, Worktree, serde_json::Value};

use crate::util::should_use_local_or_download;
use zed_extension_api::{
self as zed, LanguageServerId, LanguageServerInstallationStatus, Worktree, serde_json::Value,
set_language_server_installation_status,
};

use crate::{
config::{CheckUpdates, get_check_updates},
util::{
fresh_cached_version, record_successful_update_check, should_use_local_or_download,
update_check_path,
},
};

pub trait Downloadable {
const INSTALL_PATH: &'static str;
Expand All @@ -20,6 +29,51 @@ pub trait Downloadable {
worktree: &Worktree,
) -> zed::Result<PathBuf>;

/// Return this component's persistent update-check record path.
fn update_check_path(&self) -> PathBuf {
update_check_path(Self::INSTALL_PATH)
}

/// Resolve the version to install, using a fresh cached version in `always`
/// mode or performing a remote version check when the cache cannot be used.
///
/// The boolean indicates whether the update-check marker should be refreshed
/// after installation succeeds.
fn version_for_download(
&self,
language_server_id: &LanguageServerId,
configuration: &Option<Value>,
worktree: &Worktree,
) -> zed::Result<(String, bool)> {
if get_check_updates(configuration) == CheckUpdates::Always
&& let Some(version) = fresh_cached_version(&self.update_check_path())
{
return Ok((version, false));
}

set_language_server_installation_status(
language_server_id,
&LanguageServerInstallationStatus::CheckingForUpdate,
);
let version = self.fetch_latest_version(worktree);
set_language_server_installation_status(
language_server_id,
&LanguageServerInstallationStatus::None,
);
version.map(|version| (version, true))
}

/// Persist a successful remote version check without failing installation
/// when the record itself cannot be written.
fn record_update_check(&self, version: &str) {
if let Err(err) = record_successful_update_check(&self.update_check_path(), version) {
println!(
"Failed to record update check for {}: {err}",
Self::INSTALL_PATH
);
}
}

fn get_or_download(
&mut self,
language_server_id: &LanguageServerId,
Expand All @@ -30,18 +84,29 @@ pub trait Downloadable {
return Ok(PathBuf::from(path));
}

if let Some(path) =
should_use_local_or_download(configuration, self.find_local(), Self::INSTALL_PATH)?
{
if let Some(path) = should_use_local_or_download(
configuration,
self.find_local(),
Self::INSTALL_PATH,
&self.update_check_path(),
)? {
return Ok(path);
}

let downloaded = self
.fetch_latest_version(worktree)
.and_then(|version| self.download(&version, language_server_id, worktree));
.version_for_download(language_server_id, configuration, worktree)
.and_then(|(version, update_marker)| {
self.download(&version, language_server_id, worktree)
.map(|path| (path, version, update_marker))
});

match downloaded {
Ok(path) => Ok(path),
Ok((path, version, update_marker)) => {
if update_marker {
self.record_update_check(&version);
}
Ok(path)
}
// The version check or download failed (e.g. GitHub API rate
// limiting) — an existing local installation is better than none.
Err(err) => match self.find_local() {
Expand Down Expand Up @@ -76,43 +141,61 @@ mod fallback_tests {

#[test]
fn test_check_updates_always_allows_download() {
let result = should_use_local_or_download(&None, None, "jdtls").unwrap();
let result =
should_use_local_or_download(&None, None, "jdtls", &update_check_path("jdtls"))
.unwrap();
assert!(result.is_none(), "Always mode should allow download");
}

#[test]
fn test_check_updates_always_with_local_still_downloads() {
let local = PathBuf::from("/mock/jdtls/1.44.0");
let result = should_use_local_or_download(&None, Some(local), "jdtls").unwrap();
let result =
should_use_local_or_download(&None, Some(local), "jdtls", &update_check_path("jdtls"))
.unwrap();
assert!(result.is_none(), "Always mode downloads even with local");
}

#[test]
fn test_check_updates_never_with_local_uses_it() {
let config = Some(json!({"check_updates": "never"}));
let local = PathBuf::from("/mock/jdtls/1.44.0");
let result = should_use_local_or_download(&config, Some(local.clone()), "jdtls").unwrap();
let result = should_use_local_or_download(
&config,
Some(local.clone()),
"jdtls",
&update_check_path("jdtls"),
)
.unwrap();
assert_eq!(result, Some(local));
}

#[test]
fn test_check_updates_never_without_local_is_error() {
let config = Some(json!({"check_updates": "never"}));
let result = should_use_local_or_download(&config, None, "jdtls");
let result =
should_use_local_or_download(&config, None, "jdtls", &update_check_path("jdtls"));
assert!(result.is_err());
}

#[test]
fn test_check_updates_once_with_local_uses_it() {
let config = Some(json!({"check_updates": "once"}));
let local = PathBuf::from("/mock/jdtls/1.44.0");
let result = should_use_local_or_download(&config, Some(local.clone()), "jdtls").unwrap();
let result = should_use_local_or_download(
&config,
Some(local.clone()),
"jdtls",
&update_check_path("jdtls"),
)
.unwrap();
assert_eq!(result, Some(local));
}

#[test]
fn test_default_is_always() {
let result = should_use_local_or_download(&None, None, "test").unwrap();
let result =
should_use_local_or_download(&None, None, "test", &update_check_path("test")).unwrap();
assert!(result.is_none(), "Default should be Always (None)");
}
}
Loading