diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..ecb0a02 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,37 @@ +use zed_extension_api::{Worktree, serde_json::Value}; + +use crate::util::expand_home_path; + +pub fn get_java_home(configuration: &Option, worktree: &Worktree) -> Option { + // try to read the value from settings + if let Some(configuration) = configuration + && let Some(java_home) = configuration.pointer("/java/home").and_then(|x| x.as_str()) { + match expand_home_path(worktree, java_home.to_string()) { + Ok(home_path) => return Some(home_path), + Err(err) => { + println!("{}", err); + } + }; + } + + // try to read the value from env + match worktree + .shell_env() + .into_iter() + .find(|(k, _)| k == "JAVA_HOME") + { + Some((_, value)) if !value.is_empty() => Some(value), + _ => None, + } +} + +pub fn is_lombok_enabled(configuration: &Option) -> bool { + configuration + .as_ref() + .and_then(|configuration| { + configuration + .pointer("/java/jdt/ls/lombokSupport/enabled") + .and_then(|enabled| enabled.as_bool()) + }) + .unwrap_or(false) +} diff --git a/src/debugger.rs b/src/debugger.rs index ae62014..5650152 100644 --- a/src/debugger.rs +++ b/src/debugger.rs @@ -1,4 +1,4 @@ -use std::{collections::HashMap, env, fs, path::PathBuf}; +use std::{collections::HashMap, fs, path::PathBuf}; use serde::{Deserialize, Serialize}; use zed_extension_api::{ @@ -9,7 +9,10 @@ use zed_extension_api::{ set_language_server_installation_status, }; -use crate::lsp::LspWrapper; +use crate::{ + lsp::LspWrapper, + util::{get_curr_dir, path_to_string}, +}; #[derive(Serialize, Deserialize, Debug)] #[serde(rename_all = "camelCase")] @@ -53,8 +56,6 @@ const RUNTIME_SCOPE: &str = "$Runtime"; const SCOPES: [&str; 3] = [TEST_SCOPE, AUTO_SCOPE, RUNTIME_SCOPE]; -const PATH_TO_STR_ERROR: &str = "Failed to convert path to string"; - const JAVA_DEBUG_PLUGIN_FORK_URL: &str = "https://github.com/zed-industries/java-debug/releases/download/0.53.2/com.microsoft.java.debug.plugin-0.53.2.jar"; const MAVEN_METADATA_URL: &str = "https://repo1.maven.org/maven2/com/microsoft/java/com.microsoft.java.debug.plugin/maven-metadata.xml"; @@ -105,7 +106,7 @@ impl Debugger { download_file( JAVA_DEBUG_PLUGIN_FORK_URL, - jar_path.to_str().ok_or(PATH_TO_STR_ERROR)?, + &path_to_string(jar_path.clone())?, DownloadedFileType::Uncompressed, ) .map_err(|err| { @@ -215,7 +216,7 @@ impl Debugger { download_file( url.as_str(), - jar_path.to_str().ok_or(PATH_TO_STR_ERROR)?, + &path_to_string(&jar_path)?, DownloadedFileType::Uncompressed, ) .map_err(|err| format!("Failed to download {url} {err}"))?; @@ -345,8 +346,7 @@ impl Debugger { &self, initialization_options: Option, ) -> zed::Result { - let current_dir = - env::current_dir().map_err(|err| format!("could not get current dir: {err}"))?; + let current_dir = get_curr_dir()?; let canonical_path = Value::String( current_dir diff --git a/src/java.rs b/src/java.rs index 7889da0..7bc8b11 100644 --- a/src/java.rs +++ b/src/java.rs @@ -1,37 +1,42 @@ +mod config; mod debugger; +mod jdtls; mod lsp; +mod util; + use std::{ - collections::BTreeSet, env, - fs::{self, create_dir}, - path::{Path, PathBuf}, + fs::{self, metadata}, + path::PathBuf, str::FromStr, }; -use regex::Regex; -use sha1::{Digest, Sha1}; use zed_extension_api::{ - self as zed, CodeLabel, CodeLabelSpan, DebugAdapterBinary, DebugTaskDefinition, - DownloadedFileType, Extension, LanguageServerId, LanguageServerInstallationStatus, Os, - StartDebuggingRequestArguments, StartDebuggingRequestArgumentsRequest, Worktree, - current_platform, download_file, - http_client::{HttpMethod, HttpRequest, fetch}, + self as zed, CodeLabel, CodeLabelSpan, DebugAdapterBinary, DebugTaskDefinition, Extension, + LanguageServerId, LanguageServerInstallationStatus, StartDebuggingRequestArguments, + StartDebuggingRequestArgumentsRequest, Worktree, lsp::{Completion, CompletionKind}, - make_file_executable, - process::Command, register_extension, - serde_json::{self, Value, json}, + serde_json::{Value, json}, set_language_server_installation_status, settings::LspSettings, }; -use crate::{debugger::Debugger, lsp::LspWrapper}; +use crate::{ + config::{get_java_home, is_lombok_enabled}, + debugger::Debugger, + jdtls::{ + build_jdtls_launch_args, find_latest_local_jdtls, find_latest_local_lombok, + get_jdtls_launcher_from_path, try_to_fetch_and_install_latest_jdtls, + try_to_fetch_and_install_latest_lombok, + }, + lsp::LspWrapper, + util::path_to_string, +}; const PROXY_FILE: &str = include_str!("proxy.mjs"); const DEBUG_ADAPTER_NAME: &str = "Java"; -const PATH_TO_STR_ERROR: &str = "failed to convert path to string"; -const JDTLS_INSTALL_PATH: &str = "jdtls"; -const LOMBOK_INSTALL_PATH: &str = "lombok"; +const LSP_INIT_ERROR: &str = "Lsp client is not initialized yet"; struct Java { cached_binary_path: Option, @@ -43,14 +48,14 @@ impl Java { fn lsp(&mut self) -> zed::Result<&LspWrapper> { self.integrations .as_ref() - .ok_or("Lsp client is not initialized yet".to_owned()) + .ok_or(LSP_INIT_ERROR.to_string()) .map(|v| &v.0) } fn debugger(&mut self) -> zed::Result<&mut Debugger> { self.integrations .as_mut() - .ok_or("Lsp client is not initialized yet".to_owned()) + .ok_or(LSP_INIT_ERROR.to_string()) .map(|v| &mut v.1) } @@ -71,24 +76,18 @@ impl Java { // Use cached path if exists if let Some(path) = &self.cached_binary_path - && fs::metadata(path).is_ok_and(|stat| stat.is_file()) + && metadata(path).is_ok_and(|stat| stat.is_file()) { return Ok(path.clone()); } - let binary_name = match current_platform().0 { - Os::Windows => "jdtls.bat", - _ => "jdtls", - }; - // Check for latest version - set_language_server_installation_status( language_server_id, &LanguageServerInstallationStatus::CheckingForUpdate, ); - match try_to_fetch_and_install_latest_jdtls(binary_name, language_server_id) { + match try_to_fetch_and_install_latest_jdtls(language_server_id) { Ok(path) => { self.cached_binary_path = Some(path.clone()); Ok(path) @@ -114,7 +113,7 @@ impl Java { match try_to_fetch_and_install_latest_lombok(language_server_id) { Ok(path) => { self.cached_lombok_path = Some(path.clone()); - return Ok(path); + Ok(path) } Err(e) => { if let Some(local_version) = find_latest_local_lombok() { @@ -128,258 +127,6 @@ impl Java { } } -fn try_to_fetch_and_install_latest_jdtls( - binary_name: &str, - language_server_id: &LanguageServerId, -) -> zed::Result { - // Yeah, this part's all pretty terrible... - // Note to self: make it good eventually - let downloads_html = String::from_utf8( - fetch( - &HttpRequest::builder() - .method(HttpMethod::Get) - .url("https://download.eclipse.org/jdtls/milestones/") - .build()?, - ) - .map_err(|err| format!("failed to get available versions: {err}"))? - .body, - ) - .map_err(|err| format!("could not get string from downloads page response body: {err}"))?; - let mut versions = BTreeSet::new(); - let mut number_buffer = String::new(); - let mut version_buffer: (Option, Option, Option) = (None, None, None); - - for char in downloads_html.chars() { - if char.is_numeric() { - number_buffer.push(char); - } else if char == '.' { - if version_buffer.0.is_none() && !number_buffer.is_empty() { - version_buffer.0 = Some( - number_buffer - .parse() - .map_err(|err| format!("could not parse number buffer: {err}"))?, - ); - } else if version_buffer.1.is_none() && !number_buffer.is_empty() { - version_buffer.1 = Some( - number_buffer - .parse() - .map_err(|err| format!("could not parse number buffer: {err}"))?, - ); - } else { - version_buffer = (None, None, None); - } - - number_buffer.clear(); - } else { - if version_buffer.0.is_some() - && version_buffer.1.is_some() - && version_buffer.2.is_none() - { - versions.insert(( - version_buffer.0.ok_or("no major version number")?, - version_buffer.1.ok_or("no minor version number")?, - number_buffer - .parse::() - .map_err(|err| format!("could not parse number buffer: {err}"))?, - )); - } - - number_buffer.clear(); - version_buffer = (None, None, None); - } - } - - let (major, minor, patch) = versions.last().ok_or("no available versions")?; - let latest_version = format!("{major}.{minor}.{patch}"); - let latest_version_build = String::from_utf8( - fetch( - &HttpRequest::builder() - .method(HttpMethod::Get) - .url(format!( - "https://download.eclipse.org/jdtls/milestones/{latest_version}/latest.txt" - )) - .build()?, - ) - .map_err(|err| format!("failed to get latest version's build: {err}"))? - .body, - ) - .map_err(|err| { - format!("attempt to get latest version's build resulted in a malformed response: {err}") - })?; - let latest_version_build = latest_version_build.trim_end(); - let prefix = PathBuf::from(JDTLS_INSTALL_PATH); - // Exclude ".tar.gz" - let build_directory = &latest_version_build[..latest_version_build.len() - 7]; - let build_path = prefix.join(build_directory); - let binary_path = build_path.join("bin").join(binary_name); - - // If latest version isn't installed, - if !fs::metadata(&binary_path).is_ok_and(|stat| stat.is_file()) { - // then download it... - - set_language_server_installation_status( - language_server_id, - &LanguageServerInstallationStatus::Downloading, - ); - download_file( - &format!( - "https://www.eclipse.org/downloads/download.php?file=/jdtls/milestones/{latest_version}/{latest_version_build}", - ), - build_path.to_str().ok_or(PATH_TO_STR_ERROR)?, - DownloadedFileType::GzipTar, - )?; - make_file_executable(binary_path.to_str().ok_or(PATH_TO_STR_ERROR)?)?; - - // ...and delete other versions - - // This step is expected to fail sometimes, and since we don't know - // how to fix it yet, we just carry on so the user doesn't have to - // restart the language server. - match fs::read_dir(prefix) { - Ok(entries) => { - for entry in entries { - match entry { - Ok(entry) => { - if entry.file_name().to_str() != Some(build_directory) - && let Err(err) = fs::remove_dir_all(entry.path()) - { - println!("failed to remove directory entry: {err}"); - } - } - Err(err) => println!("failed to load directory entry: {err}"), - } - } - } - Err(err) => println!("failed to list prefix directory: {err}"), - } - } - - // else return jdtls base path - Ok(build_path) -} - -fn find_latest_local_jdtls() -> Option { - let prefix = PathBuf::from(JDTLS_INSTALL_PATH); - // walk the dir where we install jdtls - fs::read_dir(&prefix) - .map(|entries| { - entries - .filter_map(Result::ok) - .map(|entry| entry.path()) - .filter(|path| path.is_dir()) - // get the most recently created subdirectory - .filter_map(|path| { - let created_time = fs::metadata(&path).and_then(|meta| meta.created()).ok()?; - Some((path, created_time)) - }) - .max_by_key(|&(_, time)| time) - // and return it - .map(|(path, _)| path) - }) - .ok() - .flatten() -} - -fn try_to_fetch_and_install_latest_lombok( - language_server_id: &LanguageServerId, -) -> zed::Result { - set_language_server_installation_status( - language_server_id, - &LanguageServerInstallationStatus::CheckingForUpdate, - ); - - let tags_response_body = serde_json::from_slice::( - &fetch( - &HttpRequest::builder() - .method(HttpMethod::Get) - .url("https://api.github.com/repos/projectlombok/lombok/tags") - .build()?, - ) - .map_err(|err| format!("failed to fetch GitHub tags: {err}"))? - .body, - ) - .map_err(|err| format!("failed to deserialize GitHub tags response: {err}"))?; - let latest_version = &tags_response_body - .as_array() - .and_then(|tag| { - tag.first().and_then(|latest_tag| { - latest_tag - .get("name") - .and_then(|tag_name| tag_name.as_str()) - }) - }) - // Exclude 'v' at beginning - .ok_or("malformed GitHub tags response")?[1..]; - let prefix = LOMBOK_INSTALL_PATH; - let jar_name = format!("lombok-{latest_version}.jar"); - let jar_path = Path::new(prefix).join(&jar_name); - - // If latest version isn't installed, - if !fs::metadata(&jar_path).is_ok_and(|stat| stat.is_file()) { - // then download it... - - set_language_server_installation_status( - language_server_id, - &LanguageServerInstallationStatus::Downloading, - ); - create_dir(prefix).map_err(|err| err.to_string())?; - download_file( - &format!("https://projectlombok.org/downloads/{jar_name}"), - jar_path.to_str().ok_or(PATH_TO_STR_ERROR)?, - DownloadedFileType::Uncompressed, - )?; - - // ...and delete other versions - - // This step is expected to fail sometimes, and since we don't know - // how to fix it yet, we just carry on so the user doesn't have to - // restart the language server. - match fs::read_dir(prefix) { - Ok(entries) => { - for entry in entries { - match entry { - Ok(entry) => { - if entry.file_name().to_str() != Some(&jar_name) - && let Err(err) = fs::remove_dir_all(entry.path()) - { - println!("failed to remove directory entry: {err}"); - } - } - Err(err) => println!("failed to load directory entry: {err}"), - } - } - } - Err(err) => println!("failed to list prefix directory: {err}"), - } - } - - // else use it - Ok(jar_path) -} - -fn find_latest_local_lombok() -> Option { - let prefix = PathBuf::from(LOMBOK_INSTALL_PATH); - // walk the dir where we install lombok - fs::read_dir(&prefix) - .map(|entries| { - entries - .filter_map(Result::ok) - .map(|entry| entry.path()) - // get the most recently created jar file - .filter(|path| { - path.is_file() && path.extension().and_then(|ext| ext.to_str()) == Some("jar") - }) - .filter_map(|path| { - let created_time = fs::metadata(&path).and_then(|meta| meta.created()).ok()?; - Some((path, created_time)) - }) - .max_by_key(|&(_, time)| time) - .map(|(path, _)| path) - }) - .ok() - .flatten() -} - impl Extension for Java { fn new() -> Self where @@ -520,22 +267,10 @@ impl Extension for Java { ]; // Add lombok as javaagent if settings.java.jdt.ls.lombokSupport.enabled is true - let lombok_enabled = configuration - .as_ref() - .and_then(|configuration| { - configuration - .pointer("/java/jdt/ls/lombokSupport/enabled") - .and_then(|enabled| enabled.as_bool()) - }) - .unwrap_or(false); - - let lombok_jvm_arg = if lombok_enabled { + let lombok_jvm_arg = if is_lombok_enabled(&configuration) { let lombok_jar_path = self.lombok_jar_path(language_server_id)?; - let canonical_lombok_jar_path = current_dir - .join(lombok_jar_path) - .to_str() - .ok_or(PATH_TO_STR_ERROR)? - .to_string(); + let canonical_lombok_jar_path = path_to_string(current_dir.join(lombok_jar_path))?; + Some(format!("-javaagent:{canonical_lombok_jar_path}")) } else { None @@ -549,9 +284,9 @@ impl Extension for Java { } } else { // otherwise we launch ourselves - args.extend(self.build_jdtls_launch_args( + args.extend(build_jdtls_launch_args( + &self.language_server_binary_path(language_server_id, worktree)?, &configuration, - language_server_id, worktree, lombok_jvm_arg.into_iter().collect(), )?); @@ -731,220 +466,4 @@ impl Extension for Java { } } -impl Java { - fn build_jdtls_launch_args( - &mut self, - configuration: &Option, - language_server_id: &LanguageServerId, - worktree: &Worktree, - jvm_args: Vec, - ) -> zed::Result> { - if let Some(jdtls_launcher) = get_jdtls_launcher_from_path(worktree) { - return Ok(vec![jdtls_launcher]); - } - - let java_executable = get_java_executable(configuration, worktree)?; - let java_major_version = get_java_major_version(&java_executable)?; - if java_major_version < 21 { - return Err("JDTLS requires at least Java 21. If you need to run a JVM < 21, you can specify a different one for JDTLS to use by specifying lsp.jdtls.settings.java.home in the settings".to_string()); - } - - let extension_workdir = env::current_dir().map_err(|_e| "Could not get current dir")?; - - let jdtls_base_path = - extension_workdir.join(self.language_server_binary_path(language_server_id, worktree)?); - - let shared_config_path = get_shared_config_path(&jdtls_base_path); - let jar_path = find_equinox_launcher(&jdtls_base_path)?; - let jdtls_data_path = get_jdtls_data_path(worktree)?; - - let mut args = vec![ - get_java_executable(configuration, worktree).and_then(path_to_string)?, - "-Declipse.application=org.eclipse.jdt.ls.core.id1".to_string(), - "-Dosgi.bundles.defaultStartLevel=4".to_string(), - "-Declipse.product=org.eclipse.jdt.ls.core.product".to_string(), - "-Dosgi.checkConfiguration=true".to_string(), - format!( - "-Dosgi.sharedConfiguration.area={}", - path_to_string(shared_config_path)? - ), - "-Dosgi.sharedConfiguration.area.readOnly=true".to_string(), - "-Dosgi.configuration.cascaded=true".to_string(), - "-Xms1G".to_string(), - "--add-modules=ALL-SYSTEM".to_string(), - "--add-opens".to_string(), - "java.base/java.util=ALL-UNNAMED".to_string(), - "--add-opens".to_string(), - "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)?, - ]); - if java_major_version >= 24 { - args.push("-Djdk.xml.maxGeneralEntitySizeLimit=0".to_string()); - args.push("-Djdk.xml.totalEntitySizeLimit=0".to_string()); - } - Ok(args) - } -} - -fn path_to_string(path: PathBuf) -> zed::Result { - path.into_os_string() - .into_string() - .map_err(|_| PATH_TO_STR_ERROR.to_string()) -} - -fn get_jdtls_data_path(worktree: &Worktree) -> zed::Result { - // Note: the JDTLS data path is where JDTLS stores its own caches. - // In the unlikely event we can't find the canonical OS-Level cache-path, - // we fall back to the the extension's workdir, which may never get cleaned up. - // In future we may want to deliberately manage caches to be able to force-clean them. - - let mut env_iter = worktree.shell_env().into_iter(); - let base_cachedir = match current_platform().0 { - Os::Mac => env_iter - .find(|(k, _)| k == "HOME") - .map(|(_, v)| PathBuf::from(v).join("Library").join("Caches")), - Os::Linux => env_iter - .find(|(k, _)| k == "HOME") - .map(|(_, v)| PathBuf::from(v).join(".cache")), - Os::Windows => env_iter - .find(|(k, _)| k == "APPDATA") - .map(|(_, v)| PathBuf::from(v)), - } - .unwrap_or_else(|| { - env::current_dir() - .expect("should be able to get extension workdir") - .join("caches") - }); - - // caches are unique per worktree-root-path - let cache_key = worktree.root_path(); - - let hex_digest = get_sha1_hex(&cache_key); - let unique_dir_name = format!("jdtls-{}", hex_digest); - Ok(base_cachedir.join(unique_dir_name)) -} - -fn get_sha1_hex(input: &str) -> String { - let mut hasher = Sha1::new(); - hasher.update(input.as_bytes()); - let result = hasher.finalize(); - hex::encode(result) -} - -fn get_jdtls_launcher_from_path(worktree: &Worktree) -> Option { - let jdtls_executable_filename = match current_platform().0 { - Os::Windows => "jdtls.bat", - _ => "jdtls", - }; - - worktree.which(jdtls_executable_filename) -} - -fn get_java_executable(configuration: &Option, worktree: &Worktree) -> zed::Result { - let java_executable_filename = match current_platform().0 { - Os::Windows => "java.exe", - _ => "java", - }; - - // Get executable from $JAVA_HOME - if let Some(java_home) = get_java_home(configuration, worktree) { - let java_executable = PathBuf::from(java_home) - .join("bin") - .join(java_executable_filename); - return Ok(java_executable); - } - // If we can't, try to get it from $PATH - worktree - .which(java_executable_filename) - .map(PathBuf::from) - .ok_or_else(|| "Could not find Java executable in JAVA_HOME or on PATH".to_string()) -} - -fn get_java_home(configuration: &Option, worktree: &Worktree) -> Option { - // try to read the value from settings - if let Some(configuration) = configuration { - if let Some(java_home) = configuration - .pointer("/java/home") - .and_then(|java_home_value| java_home_value.as_str()) - { - return Some(java_home.to_string()); - } - } - - // try to read the value from env - match worktree - .shell_env() - .into_iter() - .find(|(k, _)| k == "JAVA_HOME") - { - Some((_, value)) if !value.is_empty() => Some(value), - _ => None, - } -} - -fn get_java_major_version(java_executable: &PathBuf) -> zed::Result { - let program = java_executable - .to_str() - .ok_or_else(|| "Could not convert Java executable path to string".to_string())?; - let output_bytes = Command::new(program).arg("-version").output()?.stderr; - let output = String::from_utf8(output_bytes).map_err(|e| e.to_string())?; - - let major_version_regex = - Regex::new(r#"version\s"(?P\d+)(\.\d+\.\d+(_\d+)?)?"#).map_err(|e| e.to_string())?; - let major_version = major_version_regex - .captures_iter(&output) - .find_map(|c| c.name("major").and_then(|m| m.as_str().parse::().ok())); - - if let Some(major_version) = major_version { - Ok(major_version) - } else { - Err("Could not determine Java major version".to_string()) - } -} - -fn find_equinox_launcher(jdtls_base_directory: &PathBuf) -> Result { - let plugins_dir = jdtls_base_directory.join("plugins"); - - // if we have `org.eclipse.equinox.launcher.jar` use that - let specific_launcher = plugins_dir.join("org.eclipse.equinox.launcher.jar"); - if specific_launcher.is_file() { - return Ok(specific_launcher); - } - - // else get the first file that matches the glob 'org.eclipse.equinox.launcher_*.jar' - let entries = fs::read_dir(&plugins_dir) - .map_err(|e| format!("Failed to read plugins directory: {}", e))?; - - entries - .filter_map(Result::ok) - .map(|entry| entry.path()) - .find(|path| { - path.is_file() - && path - .file_name() - .and_then(|s| s.to_str()) - .map_or(false, |s| { - s.starts_with("org.eclipse.equinox.launcher_") && s.ends_with(".jar") - }) - }) - .ok_or_else(|| "Cannot find equinox launcher".to_string()) -} - -fn get_shared_config_path(jdtls_base_directory: &PathBuf) -> PathBuf { - // Note: JDTLS also provides config_linux_arm and config_mac_arm (and others), - // but does not use them in their own launch script. It may be worth investigating if we should use them when appropriate. - let config_to_use = match current_platform().0 { - Os::Linux => "config_linux", - Os::Mac => "config_mac", - Os::Windows => "config_win", - }; - jdtls_base_directory.join(config_to_use) -} - register_extension!(Java); diff --git a/src/jdtls.rs b/src/jdtls.rs new file mode 100644 index 0000000..ceccfa5 --- /dev/null +++ b/src/jdtls.rs @@ -0,0 +1,388 @@ +use std::{ + collections::BTreeSet, + env::current_dir, + fs::{create_dir, metadata, read_dir}, + path::{Path, PathBuf}, +}; + +use sha1::{Digest, Sha1}; +use zed_extension_api::{ + self as zed, DownloadedFileType, LanguageServerId, LanguageServerInstallationStatus, Os, + Worktree, current_platform, download_file, + http_client::{HttpMethod, HttpRequest, fetch}, + make_file_executable, + serde_json::Value, + set_language_server_installation_status, +}; + +use crate::util::{ + get_curr_dir, get_java_executable, get_java_major_version, path_to_string, + remove_all_files_except, +}; + +const JDTLS_INSTALL_PATH: &str = "jdtls"; +const LOMBOK_INSTALL_PATH: &str = "lombok"; + +// Errors + +const JAVA_VERSION_ERROR: &str = "JDTLS requires at least Java 21. If you need to run a JVM < 21, you can specify a different one for JDTLS to use by specifying lsp.jdtls.settings.java.home in the settings"; + +pub fn build_jdtls_launch_args( + jdtls_path: &PathBuf, + configuration: &Option, + worktree: &Worktree, + jvm_args: Vec, +) -> zed::Result> { + if let Some(jdtls_launcher) = get_jdtls_launcher_from_path(worktree) { + return Ok(vec![jdtls_launcher]); + } + + let java_executable = get_java_executable(configuration, worktree)?; + let java_major_version = get_java_major_version(&java_executable)?; + if java_major_version < 21 { + return Err(JAVA_VERSION_ERROR.to_string()); + } + + let extension_workdir = get_curr_dir()?; + + let jdtls_base_path = extension_workdir.join(jdtls_path); + + let shared_config_path = get_shared_config_path(&jdtls_base_path); + let jar_path = find_equinox_launcher(&jdtls_base_path)?; + let jdtls_data_path = get_jdtls_data_path(worktree)?; + + let mut args = vec![ + path_to_string(java_executable)?, + "-Declipse.application=org.eclipse.jdt.ls.core.id1".to_string(), + "-Dosgi.bundles.defaultStartLevel=4".to_string(), + "-Declipse.product=org.eclipse.jdt.ls.core.product".to_string(), + "-Dosgi.checkConfiguration=true".to_string(), + format!( + "-Dosgi.sharedConfiguration.area={}", + path_to_string(shared_config_path)? + ), + "-Dosgi.sharedConfiguration.area.readOnly=true".to_string(), + "-Dosgi.configuration.cascaded=true".to_string(), + "-Xms1G".to_string(), + "--add-modules=ALL-SYSTEM".to_string(), + "--add-opens".to_string(), + "java.base/java.util=ALL-UNNAMED".to_string(), + "--add-opens".to_string(), + "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)?, + ]); + if java_major_version >= 24 { + args.push("-Djdk.xml.maxGeneralEntitySizeLimit=0".to_string()); + args.push("-Djdk.xml.totalEntitySizeLimit=0".to_string()); + } + Ok(args) +} + +pub fn find_latest_local_jdtls() -> Option { + let prefix = PathBuf::from(JDTLS_INSTALL_PATH); + // walk the dir where we install jdtls + read_dir(&prefix) + .map(|entries| { + entries + .filter_map(Result::ok) + .map(|entry| entry.path()) + .filter(|path| path.is_dir()) + // get the most recently created subdirectory + .filter_map(|path| { + let created_time = metadata(&path).and_then(|meta| meta.created()).ok()?; + Some((path, created_time)) + }) + .max_by_key(|&(_, time)| time) + // and return it + .map(|(path, _)| path) + }) + .ok() + .flatten() +} + +pub fn find_latest_local_lombok() -> Option { + let prefix = PathBuf::from(LOMBOK_INSTALL_PATH); + // walk the dir where we install lombok + read_dir(&prefix) + .map(|entries| { + entries + .filter_map(Result::ok) + .map(|entry| entry.path()) + // get the most recently created jar file + .filter(|path| { + path.is_file() && path.extension().and_then(|ext| ext.to_str()) == Some("jar") + }) + .filter_map(|path| { + let created_time = metadata(&path).and_then(|meta| meta.created()).ok()?; + Some((path, created_time)) + }) + .max_by_key(|&(_, time)| time) + .map(|(path, _)| path) + }) + .ok() + .flatten() +} + +pub fn get_jdtls_launcher_from_path(worktree: &Worktree) -> Option { + let jdtls_executable_filename = match current_platform().0 { + Os::Windows => "jdtls.bat", + _ => "jdtls", + }; + + worktree.which(jdtls_executable_filename) +} + +pub fn try_to_fetch_and_install_latest_jdtls( + language_server_id: &LanguageServerId, +) -> zed::Result { + // Yeah, this part's all pretty terrible... + // Note to self: make it good eventually + let downloads_html = String::from_utf8( + fetch( + &HttpRequest::builder() + .method(HttpMethod::Get) + .url("https://download.eclipse.org/jdtls/milestones/") + .build()?, + ) + .map_err(|err| format!("failed to get available versions: {err}"))? + .body, + ) + .map_err(|err| format!("could not get string from downloads page response body: {err}"))?; + let mut versions = BTreeSet::new(); + let mut number_buffer = String::new(); + let mut version_buffer: (Option, Option, Option) = (None, None, None); + + for char in downloads_html.chars() { + if char.is_numeric() { + number_buffer.push(char); + } else if char == '.' { + if version_buffer.0.is_none() && !number_buffer.is_empty() { + version_buffer.0 = Some( + number_buffer + .parse() + .map_err(|err| format!("could not parse number buffer: {err}"))?, + ); + } else if version_buffer.1.is_none() && !number_buffer.is_empty() { + version_buffer.1 = Some( + number_buffer + .parse() + .map_err(|err| format!("could not parse number buffer: {err}"))?, + ); + } else { + version_buffer = (None, None, None); + } + + number_buffer.clear(); + } else { + if version_buffer.0.is_some() + && version_buffer.1.is_some() + && version_buffer.2.is_none() + { + versions.insert(( + version_buffer.0.ok_or("no major version number")?, + version_buffer.1.ok_or("no minor version number")?, + number_buffer + .parse::() + .map_err(|err| format!("could not parse number buffer: {err}"))?, + )); + } + + number_buffer.clear(); + version_buffer = (None, None, None); + } + } + + let (major, minor, patch) = versions.last().ok_or("no available versions")?; + let latest_version = format!("{major}.{minor}.{patch}"); + let latest_version_build = String::from_utf8( + fetch( + &HttpRequest::builder() + .method(HttpMethod::Get) + .url(format!( + "https://download.eclipse.org/jdtls/milestones/{latest_version}/latest.txt" + )) + .build()?, + ) + .map_err(|err| format!("failed to get latest version's build: {err}"))? + .body, + ) + .map_err(|err| { + format!("attempt to get latest version's build resulted in a malformed response: {err}") + })?; + let latest_version_build = latest_version_build.trim_end(); + let prefix = PathBuf::from(JDTLS_INSTALL_PATH); + // Exclude ".tar.gz" + let build_directory = &latest_version_build[..latest_version_build.len() - 7]; + let build_path = prefix.join(build_directory); + let binary_path = build_path.join("bin").join(get_binary_name()); + + // If latest version isn't installed, + if !metadata(&binary_path).is_ok_and(|stat| stat.is_file()) { + // then download it... + + set_language_server_installation_status( + language_server_id, + &LanguageServerInstallationStatus::Downloading, + ); + download_file( + &format!( + "https://www.eclipse.org/downloads/download.php?file=/jdtls/milestones/{latest_version}/{latest_version_build}", + ), + path_to_string(build_path.clone())?.as_str(), + DownloadedFileType::GzipTar, + )?; + make_file_executable(path_to_string(binary_path)?.as_str())?; + + // ...and delete other versions + let _ = remove_all_files_except(prefix, build_directory); + } + + // return jdtls base path + Ok(build_path) +} + +pub fn try_to_fetch_and_install_latest_lombok( + language_server_id: &LanguageServerId, +) -> zed::Result { + set_language_server_installation_status( + language_server_id, + &LanguageServerInstallationStatus::CheckingForUpdate, + ); + + let tags_response_body = serde_json::from_slice::( + &fetch( + &HttpRequest::builder() + .method(HttpMethod::Get) + .url("https://api.github.com/repos/projectlombok/lombok/tags") + .build()?, + ) + .map_err(|err| format!("failed to fetch GitHub tags: {err}"))? + .body, + ) + .map_err(|err| format!("failed to deserialize GitHub tags response: {err}"))?; + let latest_version = &tags_response_body + .as_array() + .and_then(|tag| { + tag.first().and_then(|latest_tag| { + latest_tag + .get("name") + .and_then(|tag_name| tag_name.as_str()) + }) + }) + // Exclude 'v' at beginning + .ok_or("malformed GitHub tags response")?[1..]; + let prefix = LOMBOK_INSTALL_PATH; + let jar_name = format!("lombok-{latest_version}.jar"); + let jar_path = Path::new(prefix).join(&jar_name); + + // If latest version isn't installed, + if !metadata(&jar_path).is_ok_and(|stat| stat.is_file()) { + // then download it... + + set_language_server_installation_status( + language_server_id, + &LanguageServerInstallationStatus::Downloading, + ); + create_dir(prefix).map_err(|err| err.to_string())?; + download_file( + &format!("https://projectlombok.org/downloads/{jar_name}"), + path_to_string(jar_path.clone())?.as_str(), + DownloadedFileType::Uncompressed, + )?; + + // ...and delete other versions + + let _ = remove_all_files_except(prefix, jar_name.as_str()); + } + + // else use it + Ok(jar_path) +} + +fn find_equinox_launcher(jdtls_base_directory: &Path) -> Result { + let plugins_dir = jdtls_base_directory.join("plugins"); + + // if we have `org.eclipse.equinox.launcher.jar` use that + let specific_launcher = plugins_dir.join("org.eclipse.equinox.launcher.jar"); + if specific_launcher.is_file() { + return Ok(specific_launcher); + } + + // else get the first file that matches the glob 'org.eclipse.equinox.launcher_*.jar' + let entries = + read_dir(&plugins_dir).map_err(|e| format!("Failed to read plugins directory: {}", e))?; + + entries + .filter_map(Result::ok) + .map(|entry| entry.path()) + .find(|path| { + path.is_file() + && path.file_name().and_then(|s| s.to_str()).is_some_and(|s| { + s.starts_with("org.eclipse.equinox.launcher_") && s.ends_with(".jar") + }) + }) + .ok_or_else(|| "Cannot find equinox launcher".to_string()) +} + +fn get_jdtls_data_path(worktree: &Worktree) -> zed::Result { + // Note: the JDTLS data path is where JDTLS stores its own caches. + // In the unlikely event we can't find the canonical OS-Level cache-path, + // we fall back to the the extension's workdir, which may never get cleaned up. + // In future we may want to deliberately manage caches to be able to force-clean them. + + let mut env_iter = worktree.shell_env().into_iter(); + let base_cachedir = match current_platform().0 { + Os::Mac => env_iter + .find(|(k, _)| k == "HOME") + .map(|(_, v)| PathBuf::from(v).join("Library").join("Caches")), + Os::Linux => env_iter + .find(|(k, _)| k == "HOME") + .map(|(_, v)| PathBuf::from(v).join(".cache")), + Os::Windows => env_iter + .find(|(k, _)| k == "APPDATA") + .map(|(_, v)| PathBuf::from(v)), + } + .unwrap_or_else(|| { + current_dir() + .expect("should be able to get extension workdir") + .join("caches") + }); + + // caches are unique per worktree-root-path + let cache_key = worktree.root_path(); + + let hex_digest = get_sha1_hex(&cache_key); + let unique_dir_name = format!("jdtls-{}", hex_digest); + Ok(base_cachedir.join(unique_dir_name)) +} + +fn get_binary_name() -> &'static str { + match current_platform().0 { + Os::Windows => "jdtls.bat", + _ => "jdtls", + } +} + +fn get_sha1_hex(input: &str) -> String { + let mut hasher = Sha1::new(); + hasher.update(input.as_bytes()); + let result = hasher.finalize(); + hex::encode(result) +} + +fn get_shared_config_path(jdtls_base_directory: &Path) -> PathBuf { + // Note: JDTLS also provides config_linux_arm and config_mac_arm (and others), + // but does not use them in their own launch script. It may be worth investigating if we should use them when appropriate. + let config_to_use = match current_platform().0 { + Os::Linux => "config_linux", + Os::Mac => "config_mac", + Os::Windows => "config_win", + }; + jdtls_base_directory.join(config_to_use) +} diff --git a/src/lsp.rs b/src/lsp.rs index c567483..73a78da 100644 --- a/src/lsp.rs +++ b/src/lsp.rs @@ -12,14 +12,14 @@ use zed_extension_api::{ serde_json::{self, Map, Value}, }; -/** - * `proxy.mjs` starts an HTTP server and writes its port to - * `${workdir}/proxy/${hex(project_root)}`. - * - * This allows us to send LSP requests directly from the Java extension. - * It’s a temporary workaround until `zed_extension_api` - * provides the ability to send LSP requests directly. -*/ +/// +/// `proxy.mjs` starts an HTTP server and writes its port to +/// `${workdir}/proxy/${hex(project_root)}`. +/// +/// This allows us to send LSP requests directly from the Java extension. +/// It’s a temporary workaround until `zed_extension_api` +/// provides the ability to send LSP requests directly. +/// pub struct LspClient { workspace: String, } diff --git a/src/util.rs b/src/util.rs new file mode 100644 index 0000000..2c44c2e --- /dev/null +++ b/src/util.rs @@ -0,0 +1,190 @@ +use regex::Regex; +use std::{ + env::current_dir, + fs, + path::{Path, PathBuf}, +}; +use zed_extension_api::{self as zed, Command, Os, Worktree, current_platform, serde_json::Value}; + +use crate::config::get_java_home; + +// Errors +const EXPAND_ERROR: &str = "Failed to expand ~"; +const CURR_DIR_ERROR: &str = "Could not get current dir"; +const DIR_ENTRY_LOAD_ERROR: &str = "Failed to load directory entry"; +const DIR_ENTRY_RM_ERROR: &str = "Failed to remove directory entry"; +const DIR_ENTRY_LS_ERROR: &str = "Failed to list prefix directory"; +const PATH_TO_STR_ERROR: &str = "Failed to convert path to string"; +const JAVA_EXEC_ERROR: &str = "Failed to convert Java executable path to string"; +const JAVA_VERSION_ERROR: &str = "Failed to determine Java major version"; +const JAVA_EXEC_NOT_FOUND_ERROR: &str = "Could not find Java executable in JAVA_HOME or on PATH"; + +/// Expand ~ on Unix-like systems +/// +/// # Arguments +/// +/// * [`worktree`] Zed extension worktree with access to ENV +/// * [`path`] path to expand +/// +/// # Returns +/// +/// On Unix-like systems ~ is replaced with the value stored in HOME +/// +/// On Windows systems [`path`] is returned untouched +pub fn expand_home_path(worktree: &Worktree, path: String) -> zed::Result { + match zed::current_platform() { + (Os::Windows, _) => Ok(path), + (_, _) => worktree + .shell_env() + .iter() + .find(|&(key, _)| key == "HOME") + .map_or_else( + || Err(EXPAND_ERROR.to_string()), + |(_, value)| Ok(path.replace("~", value)), + ), + } +} + +/// Get the extension current directory +/// +/// # Returns +/// +/// The [`PathBuf`] of the extension directory +/// +/// # Errors +/// +/// This functoin will return an error if it was not possible to retrieve the current directory +pub fn get_curr_dir() -> zed::Result { + current_dir().map_err(|_| CURR_DIR_ERROR.to_string()) +} + +/// Retrieve the path to a java exec either: +/// - defined by the user in `settings.json` +/// - from PATH +/// - from JAVA_HOME +/// +/// # Arguments +/// +/// * [`configuration`] a JSON object representing the user configuration +/// * [`worktree`] Zed extension worktree +/// +/// # Returns +/// +/// Returns the path to the java exec file +/// +/// # Errors +/// +/// This function will return an error if neither PATH or JAVA_HOME led +/// to a java exec file +pub fn get_java_executable( + configuration: &Option, + worktree: &Worktree, +) -> zed::Result { + let java_executable_filename = match current_platform().0 { + Os::Windows => "java.exe", + _ => "java", + }; + + // Get executable from $JAVA_HOME + if let Some(java_home) = get_java_home(configuration, worktree) { + let java_executable = PathBuf::from(java_home) + .join("bin") + .join(java_executable_filename); + return Ok(java_executable); + } + // If we can't, try to get it from $PATH + worktree + .which(java_executable_filename) + .map(PathBuf::from) + .ok_or_else(|| JAVA_EXEC_NOT_FOUND_ERROR.to_string()) +} + +/// Retrieve the java major version accessible by the extension +/// +/// # Arguments +/// +/// * [`java_executable`] the path to a java exec file +/// +/// # Returns +/// +/// Returns the java major version +/// +/// # Errors +/// +/// This function will return an error if: +/// +/// * [`java_executable`] can't be converted into a String +/// * No major version can be determined +pub fn get_java_major_version(java_executable: &PathBuf) -> zed::Result { + let program = path_to_string(java_executable).map_err(|_| JAVA_EXEC_ERROR.to_string())?; + let output_bytes = Command::new(program).arg("-version").output()?.stderr; + let output = String::from_utf8(output_bytes).map_err(|e| e.to_string())?; + + let major_version_regex = + Regex::new(r#"version\s"(?P\d+)(\.\d+\.\d+(_\d+)?)?"#).map_err(|e| e.to_string())?; + let major_version = major_version_regex + .captures_iter(&output) + .find_map(|c| c.name("major").and_then(|m| m.as_str().parse::().ok())); + + if let Some(major_version) = major_version { + Ok(major_version) + } else { + Err(JAVA_VERSION_ERROR.to_string()) + } +} + +/// Convert [`path`] into [`String`] +/// +/// # Arguments +/// +/// * [`path`] the path of type [`AsRef`] to convert +/// +/// # Returns +/// +/// Returns a String representing [`path`] +/// +/// # Errors +/// +/// This function will return an error when the string conversion fails +pub fn path_to_string>(path: P) -> zed::Result { + path.as_ref() + .to_path_buf() + .into_os_string() + .into_string() + .map_err(|_| PATH_TO_STR_ERROR.to_string()) +} + +/// Remove all files or directories that aren't equal to [`filename`]. +/// +/// This function scans the directory given by [`prefix`] and removes any +/// file or directory whose name does not exactly match [`filename`]. +/// +/// # Arguments +/// +/// * [`prefix`] - The path to the directory to clean. See [`AsRef`] for supported types. +/// * [`filename`] - The name of the file to keep. +/// +/// # Returns +/// +/// Returns `Ok(())` on success, even if some removals fail (errors are printed to stdout). +pub fn remove_all_files_except>(prefix: P, filename: &str) -> zed::Result<()> { + match fs::read_dir(prefix) { + Ok(entries) => { + for entry in entries { + match entry { + Ok(entry) => { + if entry.file_name().to_str() != Some(filename) + && let Err(err) = fs::remove_dir_all(entry.path()) + { + println!("{msg}: {err}", msg = DIR_ENTRY_RM_ERROR, err = err); + } + } + Err(err) => println!("{msg}: {err}", msg = DIR_ENTRY_LOAD_ERROR, err = err), + } + } + } + Err(err) => println!("{msg}: {err}", msg = DIR_ENTRY_LS_ERROR, err = err), + } + + Ok(()) +}