diff --git a/README.md b/README.md index 05627d5..29abe68 100644 --- a/README.md +++ b/README.md @@ -246,6 +246,70 @@ If your project uses custom or internal Maven repositories, you should point JDT Without this, JDTLS's embedded Maven will only resolve artifacts from Maven Central, which will cause unresolved dependency errors for projects using internal or private repositories. +## Google Java Format + +The extension supports formatting Java code using the [google-java-format](https://github.com/google/google-java-format) tool. + +To configure `google-java-format`, add the following to your `settings.json`: + +```jsonc +"languages": { + "Java": { + // Instruct Zed to delegate document formatting to the language server (and our proxy) + "formatter": "language_server" + } +}, +"lsp": { + "jdtls": { + "settings": { + "google_java_format": { + // Enable or disable Google Java Format (default: false) + "enabled": true, + // The format style to use: "GOOGLE" or "AOSP" (default: "GOOGLE") + "style": "GOOGLE", + // Optional: path to your local google-java-format executable or JAR file. + // If omitted, the extension will automatically download the correct native + // binary for your platform, or fallback to the universal JAR. + "path": "/path/to/google-java-format" + } + } + } +} +``` + +If you specify a path to the JAR version of `google-java-format`, the extension will run it using your configured Java environment. + +## Palantir Java Format + +The extension supports formatting Java code using the [palantir-java-format](https://github.com/palantir/palantir-java-format) tool. + +To configure `palantir-java-format`, add the following to your `settings.json`: + +```jsonc +"languages": { + "Java": { + // Instruct Zed to delegate document formatting to the language server (and our proxy) + "formatter": "language_server" + } +}, +"lsp": { + "jdtls": { + "settings": { + "palantir_java_format": { + // Enable or disable Palantir Java Format (default: false) + "enabled": true, + // Optional: path to your local palantir-java-format executable or JAR file. + // If omitted, the extension will automatically download the correct native + // binary for your platform from Maven Central. + "path": "/path/to/palantir-java-format" + } + } + } +} +``` + +If you specify a path to the JAR version of `palantir-java-format`, the extension will run it using your configured Java environment. + ## Advanced Configuration/JDTLS initialization Options JDTLS provides many configuration options that can be passed via the `initialize` LSP-request. The extension will pass the JSON-object from `lsp.jdtls.initialization_options` in your settings on to JDTLS. Please refer to the [JDTLS Configuration Wiki Page](https://github.com/eclipse-jdtls/eclipse.jdt.ls/wiki/Running-the-JAVA-LS-server-from-the-command-line#initialize-request) for the available options and values. diff --git a/proxy/src/formatter.rs b/proxy/src/formatter.rs new file mode 100644 index 0000000..fe90fc6 --- /dev/null +++ b/proxy/src/formatter.rs @@ -0,0 +1,688 @@ +use crate::lsp::{show_message_to_user, write_to_stdout}; +use serde_json::Value; +use std::collections::HashMap; +use std::io::Write; +use std::process::{Command, Stdio}; +use std::sync::Mutex; + +pub type FormatterResult = Result>; + +/// Trait representing a Java source code formatter. +/// Formatter implementations handle their own configuration, activation state, +/// and process invocation details. +pub trait Formatter: Send + Sync { + /// Returns the unique user-facing name of the formatter. + fn name(&self) -> &'static str; + + /// Updates the formatter's settings based on incoming JSON configuration. + fn update_config(&mut self, settings: &Value); + + /// Returns whether this formatter is currently enabled. + fn is_enabled(&self) -> bool; + + /// Formats the provided original text and returns the formatted result. + fn format(&self, original_text: &str) -> FormatterResult; +} + +pub struct GoogleJavaFormatter { + enabled: bool, + style: String, + path: Option, + java_executable: Option, + workdir: String, +} + +impl GoogleJavaFormatter { + pub fn new(workdir: &str) -> Self { + let enabled = std::env::var("GOOGLE_JAVA_FORMAT_ENABLED") + .map(|v| v == "true") + .unwrap_or(false); + let style = + std::env::var("GOOGLE_JAVA_FORMAT_STYLE").unwrap_or_else(|_| "GOOGLE".to_string()); + let path = std::env::var("GOOGLE_JAVA_FORMAT_BIN").ok(); + let java_executable = std::env::var("JAVA_EXECUTABLE").ok(); + + Self { + enabled, + style, + path, + java_executable, + workdir: workdir.to_string(), + } + } +} + +impl Formatter for GoogleJavaFormatter { + fn name(&self) -> &'static str { + "google-java-format" + } + + fn is_enabled(&self) -> bool { + self.enabled + } + + fn update_config(&mut self, settings: &Value) { + if let Some(gjf) = find_google_java_format_config(settings) { + self.enabled = gjf + .get("enabled") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + if let Some(style) = gjf.get("style").and_then(|v| v.as_str()) { + self.style = style.to_string(); + } + if let Some(path) = gjf.get("path").and_then(|v| v.as_str()) { + self.path = Some(path.to_string()); + } + } else if is_settings_payload(settings) { + self.enabled = false; + } + } + + fn format(&self, original_text: &str) -> FormatterResult { + let path = self + .path + .clone() + .or_else(|| find_latest_local_google_java_format(&self.workdir)) + .ok_or_else(|| { + let msg = "Google Java Format is enabled but the binary was not found. Please restart Zed or reload the workspace to download it."; + show_message_to_user(msg); + Box::::from(msg) + })?; + + let mut cmd = if path.ends_with(".jar") { + let java_exe = self.java_executable.as_deref().unwrap_or("java"); + let mut c = Command::new(java_exe); + c.arg("-jar").arg(path); + c + } else { + Command::new(path) + }; + if self.style == "AOSP" { + cmd.arg("--aosp"); + } + cmd.arg("-"); + + let mut child = cmd + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|err| { + let msg = format!("Failed to execute google-java-format: {err}. Please check your Java installation or settings."); + show_message_to_user(&msg); + err + })?; + { + let mut stdin = child.stdin.take().ok_or_else(|| { + Box::::from( + "Failed to open stdin for formatter", + ) + })?; + stdin.write_all(original_text.as_bytes())?; + } + + let output = child.wait_with_output()?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(Box::::from(format!( + "Formatter exited with error: {}", + stderr + ))); + } + + let formatted = String::from_utf8(output.stdout)?; + + Ok(formatted) + } +} +fn find_google_java_format_config(value: &Value) -> Option<&Value> { + if let Some(obj) = value.as_object() { + if let Some(gjf) = obj.get("google_java_format") { + return Some(gjf); + } + for val in obj.values() { + if let Some(found) = find_google_java_format_config(val) { + return Some(found); + } + } + } else if let Some(arr) = value.as_array() { + for val in arr { + if let Some(found) = find_google_java_format_config(val) { + return Some(found); + } + } + } + None +} + +fn find_latest_local_google_java_format(workdir: &str) -> Option { + let install_dir = std::path::PathBuf::from(workdir).join("google-java-format"); + if !install_dir.exists() { + return None; + } + let mut entries = std::fs::read_dir(&install_dir) + .ok()? + .filter_map(Result::ok) + .filter(|e| e.file_type().map(|t| t.is_file()).unwrap_or(false)) + .map(|e| e.path()) + .collect::>(); + + entries.sort(); + entries + .into_iter() + .next_back() + .map(|p| p.to_string_lossy().to_string()) +} + +fn find_latest_local_palantir_java_format(workdir: &str) -> Option { + let install_dir = std::path::PathBuf::from(workdir).join("palantir-java-format"); + if !install_dir.exists() { + return None; + } + let mut entries = std::fs::read_dir(&install_dir) + .ok()? + .filter_map(Result::ok) + .filter(|e| e.file_type().map(|t| t.is_file()).unwrap_or(false)) + .map(|e| e.path()) + .collect::>(); + + entries.sort(); + entries + .into_iter() + .next_back() + .map(|p| p.to_string_lossy().to_string()) +} + +pub struct PalantirJavaFormatter { + enabled: bool, + path: Option, + java_executable: Option, + workdir: String, +} + +impl PalantirJavaFormatter { + pub fn new(workdir: &str) -> Self { + let enabled = std::env::var("PALANTIR_JAVA_FORMAT_ENABLED") + .map(|v| v == "true") + .unwrap_or(false); + let path = std::env::var("PALANTIR_JAVA_FORMAT_BIN").ok(); + let java_executable = std::env::var("JAVA_EXECUTABLE").ok(); + + Self { + enabled, + path, + java_executable, + workdir: workdir.to_string(), + } + } +} + +impl Formatter for PalantirJavaFormatter { + fn name(&self) -> &'static str { + "palantir-java-format" + } + + fn is_enabled(&self) -> bool { + self.enabled + } + + fn update_config(&mut self, settings: &Value) { + if let Some(pjf) = find_palantir_java_format_config(settings) { + self.enabled = pjf + .get("enabled") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + if let Some(path) = pjf.get("path").and_then(|v| v.as_str()) { + self.path = Some(path.to_string()); + } + } else if is_settings_payload(settings) { + self.enabled = false; + } + } + + fn format(&self, original_text: &str) -> FormatterResult { + let path = self + .path + .clone() + .or_else(|| find_latest_local_palantir_java_format(&self.workdir)) + .ok_or_else(|| { + let msg = "Palantir Java Format is enabled but the binary was not found. Please restart Zed or reload the workspace to download it."; + show_message_to_user(msg); + Box::::from(msg) + })?; + + let mut cmd = if path.ends_with(".jar") { + let java_exe = self.java_executable.as_deref().unwrap_or("java"); + let mut c = Command::new(java_exe); + c.arg("-jar").arg(path); + c + } else { + Command::new(path) + }; + cmd.arg("--palantir").arg("-"); + + let mut child = cmd + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|err| { + let msg = format!( + "Failed to execute palantir-java-format: {err}. Please check your settings." + ); + show_message_to_user(&msg); + err + })?; + { + let mut stdin = child.stdin.take().ok_or_else(|| { + Box::::from( + "Failed to open stdin for formatter", + ) + })?; + stdin.write_all(original_text.as_bytes())?; + } + + let output = child.wait_with_output()?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(Box::::from(format!( + "Formatter exited with error: {}", + stderr + ))); + } + + let formatted = String::from_utf8(output.stdout)?; + + Ok(formatted) + } +} + +fn is_settings_payload(value: &Value) -> bool { + match value { + Value::Object(map) => { + map.contains_key("settings") + || map.contains_key("google_java_format") + || map.contains_key("palantir_java_format") + || map.contains_key("java") + } + _ => false, + } +} + +fn find_palantir_java_format_config(value: &Value) -> Option<&Value> { + if let Some(obj) = value.as_object() { + if let Some(pjf) = obj.get("palantir_java_format") { + return Some(pjf); + } + for val in obj.values() { + if let Some(found) = find_palantir_java_format_config(val) { + return Some(found); + } + } + } else if let Some(arr) = value.as_array() { + for val in arr { + if let Some(found) = find_palantir_java_format_config(val) { + return Some(found); + } + } + } + None +} + +pub struct FormatterState { + pub formatters: Mutex>>, + pub document_cache: Mutex>, +} + +impl FormatterState { + pub fn new(workdir: &str) -> Self { + Self { + formatters: Mutex::new(vec![ + Box::new(GoogleJavaFormatter::new(workdir)), + Box::new(PalantirJavaFormatter::new(workdir)), + ]), + document_cache: Mutex::new(HashMap::new()), + } + } + + pub fn handle_did_open(&self, msg: &Value) { + if let Some(params) = msg.get("params") { + if let Some(text_document) = params.get("textDocument") { + if let Some(uri) = text_document.get("uri").and_then(|v| v.as_str()) { + if let Some(text) = text_document.get("text").and_then(|v| v.as_str()) { + self.document_cache + .lock() + .unwrap() + .insert(uri.to_string(), text.to_string()); + } + } + } + } + } + + pub fn handle_did_change(&self, msg: &Value) { + if let Some(params) = msg.get("params") { + if let Some(text_document) = params.get("textDocument") { + if let Some(uri) = text_document.get("uri").and_then(|v| v.as_str()) { + if let Some(content_changes) = + params.get("contentChanges").and_then(|v| v.as_array()) + { + if let Some(first_change) = content_changes.first() { + if let Some(text) = first_change.get("text").and_then(|v| v.as_str()) { + self.document_cache + .lock() + .unwrap() + .insert(uri.to_string(), text.to_string()); + } + } + } + } + } + } + } + + pub fn handle_did_close(&self, msg: &Value) { + if let Some(params) = msg.get("params") { + if let Some(text_document) = params.get("textDocument") { + if let Some(uri) = text_document.get("uri").and_then(|v| v.as_str()) { + self.document_cache.lock().unwrap().remove(uri); + } + } + } + } + + pub fn update_config(&self, settings: &Value) { + let mut formatters = self.formatters.lock().unwrap(); + for formatter in formatters.iter_mut() { + formatter.update_config(settings); + } + } + + pub fn handle_formatting_request(&self, msg: &Value) -> bool { + let formatters = self.formatters.lock().unwrap(); + let active_formatter = formatters.iter().find(|f| f.is_enabled()); + + let Some(formatter) = active_formatter else { + return false; + }; + + crate::lsp_info!("Formatting document using {}", formatter.name()); + + let Some(id) = msg.get("id") else { + return false; + }; + let Some(params) = msg.get("params") else { + return false; + }; + let Some(text_document) = params.get("textDocument") else { + return false; + }; + let Some(uri) = text_document.get("uri").and_then(|v| v.as_str()) else { + return false; + }; + + let original_text = self.document_cache.lock().unwrap().get(uri).cloned(); + + let original_text = match original_text { + Some(text) => text, + None => { + crate::lsp_error!("Formatting failed: Document not found in cache: {}", uri); + return false; + } + }; + + match formatter.format(&original_text) { + Ok(formatted_text) => { + let (end_line, end_char) = get_full_range(&original_text); + + let response = serde_json::json!({ + "jsonrpc": "2.0", + "id": id, + "result": [ + { + "range": { + "start": { "line": 0, "character": 0 }, + "end": { "line": end_line, "character": end_char } + }, + "newText": formatted_text + } + ] + }); + write_to_stdout(&response); + } + Err(err) => { + crate::lsp_error!("{} failed: {}", formatter.name(), err); + let response = serde_json::json!({ + "jsonrpc": "2.0", + "id": id, + "error": { + "code": -32603, + "message": format!("{} failed: {}", formatter.name(), err) + } + }); + write_to_stdout(&response); + } + } + true + } +} + +pub fn intercept_capabilities(msg: &mut Value) { + if let Some(result) = msg.get_mut("result") { + if let Some(capabilities) = result.get_mut("capabilities") { + if let Some(obj) = capabilities.as_object_mut() { + obj.insert("textDocumentSync".to_string(), serde_json::json!(1)); + } + } + } +} + +fn get_full_range(text: &str) -> (u32, u32) { + let mut line_count = 0; + let mut last_line_len = 0; + for line in text.split('\n') { + line_count += 1; + last_line_len = line.chars().count(); + } + let end_line = if line_count > 0 { line_count - 1 } else { 0 }; + (end_line as u32, last_line_len as u32) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_google_java_format_dynamic_reconfiguration() { + let mut formatter = GoogleJavaFormatter::new("."); + assert!(!formatter.is_enabled()); + + let settings = serde_json::json!({ + "google_java_format": { + "enabled": true, + "style": "AOSP", + "path": "/some/path" + } + }); + + formatter.update_config(&settings); + assert!(formatter.is_enabled()); + assert_eq!(formatter.style, "AOSP"); + assert_eq!(formatter.path.unwrap(), "/some/path"); + } + + #[test] + fn test_document_cache_lifecycle() { + let state = FormatterState::new("."); + let uri = "file:///home/test/Main.java"; + + let open_msg = serde_json::json!({ + "params": { + "textDocument": { + "uri": uri, + "text": "public class Main {}" + } + } + }); + state.handle_did_open(&open_msg); + assert_eq!( + state.document_cache.lock().unwrap().get(uri).unwrap(), + "public class Main {}" + ); + + let change_msg = serde_json::json!({ + "params": { + "textDocument": { + "uri": uri + }, + "contentChanges": [ + { + "text": "public class Main {\n // Changed\n}" + } + ] + } + }); + state.handle_did_change(&change_msg); + assert_eq!( + state.document_cache.lock().unwrap().get(uri).unwrap(), + "public class Main {\n // Changed\n}" + ); + + let close_msg = serde_json::json!({ + "params": { + "textDocument": { + "uri": uri + } + } + }); + state.handle_did_close(&close_msg); + assert!(state.document_cache.lock().unwrap().get(uri).is_none()); + } + + #[test] + fn test_intercept_capabilities() { + let mut capabilities = serde_json::json!({ + "result": { + "capabilities": { + "textDocumentSync": 2 + } + } + }); + intercept_capabilities(&mut capabilities); + assert_eq!( + capabilities["result"]["capabilities"]["textDocumentSync"], + 1 + ); + } + + #[test] + fn test_formatting_request_when_disabled() { + let state = FormatterState::new("."); + let request = serde_json::json!({ + "id": 1, + "params": { + "textDocument": { + "uri": "file:///home/test/Main.java" + } + } + }); + // Formatter is disabled by default, so it should return false + assert!(!state.handle_formatting_request(&request)); + } + + #[test] + fn test_get_full_range() { + assert_eq!(get_full_range(""), (0, 0)); + assert_eq!(get_full_range("hello"), (0, 5)); + assert_eq!(get_full_range("hello\nworld"), (1, 5)); + assert_eq!(get_full_range("hello\nworld\n"), (2, 0)); + } + + #[test] + fn test_formatter_state_update_config() { + let state = FormatterState::new("."); + let settings = serde_json::json!({ + "google_java_format": { + "enabled": true, + "style": "AOSP" + } + }); + state.update_config(&settings); + + let formatters = state.formatters.lock().unwrap(); + assert!(formatters[0].is_enabled()); + } + + #[test] + fn test_palantir_dynamic_reconfiguration() { + let mut formatter = PalantirJavaFormatter::new("."); + assert!(!formatter.is_enabled()); + + let settings = serde_json::json!({ + "palantir_java_format": { + "enabled": true, + "path": "/some/palantir/path" + } + }); + + formatter.update_config(&settings); + assert!(formatter.is_enabled()); + assert_eq!(formatter.path.unwrap(), "/some/palantir/path"); + } + + #[test] + fn test_find_config_nested() { + let settings_arr = serde_json::json!([ + { + "other_key": 1 + }, + { + "google_java_format": { + "enabled": true + } + } + ]); + assert!(find_google_java_format_config(&settings_arr).is_some()); + + let settings_nested = serde_json::json!({ + "level1": { + "level2": { + "google_java_format": { + "style": "GOOGLE" + } + } + } + }); + assert_eq!( + find_google_java_format_config(&settings_nested).unwrap()["style"], + "GOOGLE" + ); + } + + #[test] + fn test_disable_formatters() { + let state = FormatterState::new("."); + let settings_enabled = serde_json::json!({ + "google_java_format": { + "enabled": true + } + }); + state.update_config(&settings_enabled); + { + let formatters = state.formatters.lock().unwrap(); + assert!(formatters[0].is_enabled()); + assert!(!formatters[1].is_enabled()); + } + let settings_commented = serde_json::json!({ + "settings": { + "java": {} + } + }); + state.update_config(&settings_commented); + { + let formatters = state.formatters.lock().unwrap(); + assert!(!formatters[0].is_enabled()); + assert!(!formatters[1].is_enabled()); + } + } +} diff --git a/proxy/src/lsp.rs b/proxy/src/lsp.rs index 7493861..f07239e 100644 --- a/proxy/src/lsp.rs +++ b/proxy/src/lsp.rs @@ -83,3 +83,16 @@ pub fn write_to_stdout(value: &impl Serialize) { let _ = w.write_all(out.as_bytes()); let _ = w.flush(); } + +/// Send a window/showMessage LSP notification to display a pop-up in the client. +pub fn show_message_to_user(msg: &str) { + let notification = serde_json::json!({ + "jsonrpc": "2.0", + "method": "window/showMessage", + "params": { + "type": 1, // Error + "message": msg + } + }); + write_to_stdout(¬ification); +} diff --git a/proxy/src/main.rs b/proxy/src/main.rs index b07ce68..00e7436 100644 --- a/proxy/src/main.rs +++ b/proxy/src/main.rs @@ -1,5 +1,6 @@ mod completions; mod decompile; +mod formatter; mod http; mod log; mod lsp; @@ -7,12 +8,13 @@ mod platform; use completions::{is_completion_response, process_completions, sanitize_resolved_completion}; use decompile::{rewrite_jdt_in_strings, rewrite_jdt_locations}; +use formatter::{intercept_capabilities, FormatterState}; use http::handle_http; use lsp::{parse_lsp_content, raw_has_id, write_raw, write_to_stdout, LspReader}; use platform::spawn_parent_monitor; use serde_json::Value; use std::{ - collections::HashMap, + collections::{HashMap, HashSet}, env, fs, io::{self, BufReader, Write}, net::TcpListener, @@ -100,22 +102,30 @@ fn main() { // so their responses can be intercepted and rewritten. let tracked_ids: Arc>> = Arc::new(Mutex::new(HashMap::new())); - // --- Thread 1: Zed stdin -> JDTLS stdin (track definition requests) --- + let formatter = Arc::new(FormatterState::new(workdir)); + let pending_config_ids = Arc::new(Mutex::new(HashSet::new())); + + // --- Thread 1: Zed stdin -> JDTLS stdin (track definition requests, GJF redirection, doc cache) --- let stdin_writer = Arc::clone(&child_stdin); let alive_stdin = Arc::clone(&alive); let tracked_in = Arc::clone(&tracked_ids); + let formatter_in = Arc::clone(&formatter); + let pending_config_ids_in = Arc::clone(&pending_config_ids); thread::spawn(move || { let stdin = io::stdin().lock(); let mut reader = LspReader::new(BufReader::new(stdin)); while alive_stdin.load(Ordering::Relaxed) { match reader.read_message() { Ok(Some(raw)) => { - // Only requests (not notifications) carry an `id`; skip the - // JSON parse entirely for high-volume notifications like - // textDocument/didChange. + let msg_parsed = parse_lsp_content(&raw); if raw_has_id(&raw) { - if let Some(msg) = parse_lsp_content(&raw) { + if let Some(ref msg) = msg_parsed { if let Some(method) = msg.get("method").and_then(|m| m.as_str()) { + if method == "textDocument/formatting" + && formatter_in.handle_formatting_request(msg) + { + continue; + } let kind = match method { "textDocument/definition" | "textDocument/typeDefinition" @@ -133,6 +143,39 @@ fn main() { } } } + + if let Some(id) = msg.get("id") { + let is_config_resp = + { pending_config_ids_in.lock().unwrap().remove(id) }; + if is_config_resp { + if let Some(result) = msg.get("result") { + formatter_in.update_config(result); + } + } + } + } + } else { + // Notifications. + if let Some(ref msg) = msg_parsed { + if let Some(method) = msg.get("method").and_then(|m| m.as_str()) { + match method { + "textDocument/didOpen" => { + formatter_in.handle_did_open(msg); + } + "textDocument/didChange" => { + formatter_in.handle_did_change(msg); + } + "textDocument/didClose" => { + formatter_in.handle_did_close(msg); + } + "workspace/didChangeConfiguration" => { + if let Some(params) = msg.get("params") { + formatter_in.update_config(params); + } + } + _ => {} + } + } } } let mut w = stdin_writer.lock().unwrap(); @@ -154,6 +197,7 @@ fn main() { let decompile_pending = Arc::clone(&pending); let decompile_counter = Arc::clone(&id_counter); let decompile_proxy_id = proxy_id.clone(); + let pending_config_ids_out = Arc::clone(&pending_config_ids); thread::spawn(move || { let mut reader = LspReader::new(BufReader::new(child_stdout)); while alive_out.load(Ordering::Relaxed) { @@ -171,6 +215,26 @@ fn main() { continue; }; + // Check if JDTLS is requesting workspace configuration, + if let Some(method) = msg.get("method").and_then(|m| m.as_str()) { + if method == "workspace/configuration" { + if let Some(id) = msg.get("id").cloned() { + pending_config_ids_out.lock().unwrap().insert(id); + } + } + } + + // Intercept initialize response to rewrite textDocumentSync capability. + if msg + .get("result") + .and_then(|r| r.get("capabilities")) + .is_some() + { + intercept_capabilities(&mut msg); + write_to_stdout(&msg); + continue; + } + // Route responses to pending HTTP requests if let Some(id) = msg.get("id") { if let Some(tx) = pending_out.lock().unwrap().remove(id) { diff --git a/src/config.rs b/src/config.rs index 7d0553d..9ed5ab3 100644 --- a/src/config.rs +++ b/src/config.rs @@ -162,3 +162,126 @@ pub fn get_lsp_proxy_path(configuration: &Option, worktree: &Worktree) -> None } + +#[derive(Debug, Clone, PartialEq)] +pub struct GoogleJavaFormatConfig { + pub enabled: bool, + pub path: Option, + pub style: String, +} +pub fn is_google_java_format_enabled(configuration: &Option) -> bool { + configuration + .as_ref() + .and_then(|c| { + c.pointer("/google_java_format") + .and_then(|gjf| gjf.get("enabled")) + .and_then(|v| v.as_bool()) + }) + .unwrap_or(false) +} + +pub fn get_google_java_format_config( + configuration: &Option, + worktree: &Worktree, +) -> GoogleJavaFormatConfig { + let mut config = GoogleJavaFormatConfig { + enabled: false, + path: None, + style: "GOOGLE".to_string(), + }; + + if let Some(configuration) = configuration + && let Some(gjf) = configuration.pointer("/google_java_format") + { + if let Some(enabled) = gjf.get("enabled").and_then(|v| v.as_bool()) { + config.enabled = enabled; + } + if let Some(path) = gjf.get("path").and_then(|v| v.as_str()) + && let Ok(p) = expand_home_path(worktree, path.to_string()) + { + config.path = Some(p); + } + if let Some(style) = gjf.get("style").and_then(|v| v.as_str()) { + config.style = style.to_string(); + } + } + + config +} + +#[derive(Debug, Clone, PartialEq)] +pub struct PalantirJavaFormatConfig { + pub enabled: bool, + pub path: Option, +} +pub fn is_palantir_java_format_enabled(configuration: &Option) -> bool { + configuration + .as_ref() + .and_then(|c| { + c.pointer("/palantir_java_format") + .and_then(|pjf| pjf.get("enabled")) + .and_then(|v| v.as_bool()) + }) + .unwrap_or(false) +} + +pub fn get_palantir_java_format_config( + configuration: &Option, + worktree: &Worktree, +) -> PalantirJavaFormatConfig { + let mut config = PalantirJavaFormatConfig { + enabled: false, + path: None, + }; + + if let Some(configuration) = configuration + && let Some(pjf) = configuration.pointer("/palantir_java_format") + { + if let Some(enabled) = pjf.get("enabled").and_then(|v| v.as_bool()) { + config.enabled = enabled; + } + if let Some(path) = pjf.get("path").and_then(|v| v.as_str()) + && let Ok(p) = expand_home_path(worktree, path.to_string()) + { + config.path = Some(p); + } + } + + config +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn test_is_google_java_format_enabled() { + assert!(!is_google_java_format_enabled(&None)); + assert!(!is_google_java_format_enabled(&Some(json!({})))); + assert!(!is_google_java_format_enabled(&Some( + json!({ "google_java_format": {} }) + ))); + assert!(!is_google_java_format_enabled(&Some( + json!({ "google_java_format": { "enabled": false } }) + ))); + assert!(is_google_java_format_enabled(&Some( + json!({ "google_java_format": { "enabled": true } }) + ))); + } + + #[test] + fn test_is_palantir_java_format_enabled() { + assert!(!is_palantir_java_format_enabled(&None)); + assert!(!is_palantir_java_format_enabled(&Some(json!({})))); + assert!(!is_palantir_java_format_enabled(&Some( + json!({ "palantir_java_format": {} }) + ))); + assert!(!is_palantir_java_format_enabled(&Some( + json!({ "palantir_java_format": { "enabled": false } }) + ))); + assert!(is_palantir_java_format_enabled(&Some( + json!({ "palantir_java_format": { "enabled": true } }) + ))); + } +} diff --git a/src/java.rs b/src/java.rs index 29061d5..116846d 100644 --- a/src/java.rs +++ b/src/java.rs @@ -25,7 +25,11 @@ use zed_extension_api::{ }; use crate::{ - config::{get_java_home, get_jdtls_launcher, get_lombok_jar, is_lombok_enabled}, + config::{ + get_google_java_format_config, get_java_home, get_jdtls_launcher, get_lombok_jar, + get_palantir_java_format_config, is_google_java_format_enabled, is_lombok_enabled, + is_palantir_java_format_enabled, + }, debugger::Debugger, jdtls::{ build_jdtls_launch_args, find_latest_local_jdtls, find_latest_local_lombok, @@ -142,6 +146,107 @@ impl Java { } } } + + fn google_java_format_env( + &mut self, + language_server_id: &LanguageServerId, + configuration: &Option, + worktree: &Worktree, + env: &mut Vec<(String, String)>, + ) -> zed::Result<()> { + let gjf_config = get_google_java_format_config(configuration, worktree); + + env.push(( + "GOOGLE_JAVA_FORMAT_ENABLED".to_string(), + gjf_config.enabled.to_string(), + )); + env.push(("GOOGLE_JAVA_FORMAT_STYLE".to_string(), gjf_config.style)); + + let path = if let Some(custom_path) = gjf_config.path { + PathBuf::from(custom_path) + } else { + let rel_path = match download_google_java_format(language_server_id, configuration) { + Ok(path) => path, + Err(err) => { + if let Some(local_path) = find_latest_local_google_java_format() { + local_path + } else { + return Err(err); + } + } + }; + if let Ok(current_dir) = env::current_dir() { + current_dir.join(rel_path) + } else { + rel_path + } + }; + + let path_str = path.to_string_lossy().to_string(); + env.push(("GOOGLE_JAVA_FORMAT_BIN".to_string(), path_str.clone())); + + if path_str.ends_with(".jar") + && let Ok(java_exe) = + crate::util::get_java_executable(configuration, worktree, language_server_id) + { + env.push(( + "JAVA_EXECUTABLE".to_string(), + java_exe.to_string_lossy().to_string(), + )); + } + + Ok(()) + } + + fn palantir_java_format_env( + &mut self, + language_server_id: &LanguageServerId, + configuration: &Option, + worktree: &Worktree, + env: &mut Vec<(String, String)>, + ) -> zed::Result<()> { + let pjf_config = get_palantir_java_format_config(configuration, worktree); + + env.push(( + "PALANTIR_JAVA_FORMAT_ENABLED".to_string(), + pjf_config.enabled.to_string(), + )); + + let path = if let Some(custom_path) = pjf_config.path { + PathBuf::from(custom_path) + } else { + let rel_path = match download_palantir_java_format(language_server_id, configuration) { + Ok(path) => path, + Err(err) => { + if let Some(local_path) = find_latest_local_palantir_java_format() { + local_path + } else { + return Err(err); + } + } + }; + if let Ok(current_dir) = env::current_dir() { + current_dir.join(rel_path) + } else { + rel_path + } + }; + + let path_str = path.to_string_lossy().to_string(); + env.push(("PALANTIR_JAVA_FORMAT_BIN".to_string(), path_str.clone())); + + if path_str.ends_with(".jar") + && let Ok(java_exe) = + crate::util::get_java_executable(configuration, worktree, language_server_id) + { + env.push(( + "JAVA_EXECUTABLE".to_string(), + java_exe.to_string_lossy().to_string(), + )); + } + + Ok(()) + } } impl Extension for Java { @@ -287,6 +392,14 @@ impl Extension for Java { let mut env = Vec::new(); + if is_google_java_format_enabled(&configuration) { + self.google_java_format_env(language_server_id, &configuration, worktree, &mut env)?; + } + + if is_palantir_java_format_enabled(&configuration) { + self.palantir_java_format_env(language_server_id, &configuration, worktree, &mut env)?; + } + if let Some(java_home) = get_java_home(&configuration, worktree) { env.push(("JAVA_HOME".to_string(), java_home)); } @@ -632,4 +745,212 @@ impl Extension for Java { } } +fn find_latest_local_google_java_format() -> Option { + let install_dir = PathBuf::from("google-java-format"); + if !install_dir.exists() { + return None; + } + let mut entries = std::fs::read_dir(&install_dir) + .ok()? + .filter_map(Result::ok) + .filter(|e| e.file_type().map(|t| t.is_file()).unwrap_or(false)) + .map(|e| e.path()) + .collect::>(); + + entries.sort(); + entries.into_iter().next_back() +} + +fn download_google_java_format( + language_server_id: &LanguageServerId, + _configuration: &Option, +) -> zed::Result { + set_language_server_installation_status( + language_server_id, + &LanguageServerInstallationStatus::CheckingForUpdate, + ); + + let repo = "google/google-java-format"; + let release = zed::latest_github_release( + repo, + zed::GithubReleaseOptions { + require_assets: true, + pre_release: false, + }, + )?; + + let install_dir = PathBuf::from("google-java-format"); + let clean_version = release.version.trim_start_matches('v'); + + let (os, arch) = zed::current_platform(); + let native_asset_name = match (os, arch) { + (zed::Os::Mac, zed::Architecture::Aarch64) => { + Some("google-java-format_darwin-arm64".to_string()) + } + (zed::Os::Linux, zed::Architecture::X8664) => { + Some("google-java-format_linux-x86-64".to_string()) + } + (zed::Os::Linux, zed::Architecture::Aarch64) => { + Some("google-java-format_linux-arm64".to_string()) + } + (zed::Os::Windows, zed::Architecture::X8664) => { + Some("google-java-format_windows-x86-64.exe".to_string()) + } + _ => None, + }; + + let (asset_name, target_file_name, is_jar) = if let Some(name) = native_asset_name { + let suffix = name.strip_prefix("google-java-format").unwrap_or(&name); + let target_name = format!("google-java-format-{}{}", release.version, suffix); + (name, target_name, false) + } else { + let name = format!("google-java-format-{clean_version}-all-deps.jar"); + (name.clone(), name, true) + }; + + let target_path = install_dir.join(&target_file_name); + + if target_path.exists() { + set_language_server_installation_status( + language_server_id, + &LanguageServerInstallationStatus::None, + ); + return Ok(target_path); + } + + let asset = release + .assets + .iter() + .find(|a| a.name == asset_name) + .ok_or_else(|| { + format!( + "Asset '{}' not found in release {}", + asset_name, release.version + ) + })?; + + set_language_server_installation_status( + language_server_id, + &LanguageServerInstallationStatus::Downloading, + ); + + fs::create_dir_all(&install_dir) + .map_err(|e| format!("Failed to create directory {:?}: {}", install_dir, e))?; + + zed::download_file( + &asset.download_url, + &target_path.to_string_lossy(), + zed::DownloadedFileType::Uncompressed, + ) + .map_err(|e| format!("Failed to download google-java-format: {}", e))?; + + if !is_jar { + let _ = zed::make_file_executable(&target_path.to_string_lossy()); + } + + set_language_server_installation_status( + language_server_id, + &LanguageServerInstallationStatus::None, + ); + + let _ = crate::util::remove_all_files_except("google-java-format", &target_file_name); + + Ok(target_path) +} + +fn find_latest_local_palantir_java_format() -> Option { + let install_dir = PathBuf::from("palantir-java-format"); + if !install_dir.exists() { + return None; + } + let mut entries = std::fs::read_dir(&install_dir) + .ok()? + .filter_map(Result::ok) + .filter(|e| e.file_type().map(|t| t.is_file()).unwrap_or(false)) + .map(|e| e.path()) + .collect::>(); + + entries.sort(); + entries.into_iter().next_back() +} + +fn download_palantir_java_format( + language_server_id: &LanguageServerId, + _configuration: &Option, +) -> zed::Result { + set_language_server_installation_status( + language_server_id, + &LanguageServerInstallationStatus::CheckingForUpdate, + ); + + let repo = "palantir/palantir-java-format"; + let release = zed::latest_github_release( + repo, + zed::GithubReleaseOptions { + require_assets: false, + pre_release: false, + }, + )?; + + let install_dir = PathBuf::from("palantir-java-format"); + let clean_version = release.version.trim_start_matches('v'); + + let (os, arch) = zed::current_platform(); + let native_suffix = match (os, arch) { + (zed::Os::Mac, zed::Architecture::Aarch64) => Some("macos_aarch64"), + (zed::Os::Linux, zed::Architecture::X8664) => Some("linux-glibc_x86-64"), + (zed::Os::Linux, zed::Architecture::Aarch64) => Some("linux-glibc_aarch64"), + _ => None, + }; + + let Some(suffix) = native_suffix else { + return Err(format!( + "No pre-built palantir-java-format binary available for platform {:?}/{:?}", + os, arch + )); + }; + + let target_file_name = format!("palantir-java-format-native-{}-{}", clean_version, suffix); + let target_path = install_dir.join(&target_file_name); + + if target_path.exists() { + set_language_server_installation_status( + language_server_id, + &LanguageServerInstallationStatus::None, + ); + return Ok(target_path); + } + + set_language_server_installation_status( + language_server_id, + &LanguageServerInstallationStatus::Downloading, + ); + + fs::create_dir_all(&install_dir) + .map_err(|e| format!("Failed to create directory {:?}: {}", install_dir, e))?; + + let download_url = format!( + "https://repo1.maven.org/maven2/com/palantir/javaformat/palantir-java-format-native/{}/palantir-java-format-native-{}-nativeImage-{}.bin", + clean_version, clean_version, suffix + ); + + zed::download_file( + &download_url, + &target_path.to_string_lossy(), + zed::DownloadedFileType::Uncompressed, + ) + .map_err(|e| format!("Failed to download palantir-java-format: {}", e))?; + + let _ = zed::make_file_executable(&target_path.to_string_lossy()); + + set_language_server_installation_status( + language_server_id, + &LanguageServerInstallationStatus::None, + ); + + let _ = crate::util::remove_all_files_except("palantir-java-format", &target_file_name); + + Ok(target_path) +} + register_extension!(Java);