From 93b78cac0d18bff8f9a709249839cf966b5243a9 Mon Sep 17 00:00:00 2001 From: DeityLamb Date: Thu, 14 Aug 2025 12:59:51 +0300 Subject: [PATCH 1/9] wip: lsp proxy for debug adapter --- Cargo.toml | 2 +- debug_adapter_schemas/Java.json | 1 + extension.toml | 3 + src/lib.rs | 233 +++++++++++++++++++++++++++++--- src/proxy.mjs | 174 ++++++++++++++++++++++++ 5 files changed, 392 insertions(+), 21 deletions(-) create mode 100644 debug_adapter_schemas/Java.json create mode 100644 src/proxy.mjs diff --git a/Cargo.toml b/Cargo.toml index 315687c..ba505ea 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,4 +7,4 @@ edition = "2024" crate-type = ["cdylib"] [dependencies] -zed_extension_api = "0.3.0" +zed_extension_api = "0.6.0" diff --git a/debug_adapter_schemas/Java.json b/debug_adapter_schemas/Java.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/debug_adapter_schemas/Java.json @@ -0,0 +1 @@ +{} diff --git a/extension.toml b/extension.toml index 827e59a..a8cccaf 100644 --- a/extension.toml +++ b/extension.toml @@ -21,3 +21,6 @@ commit = "579b62f5ad8d96c2bb331f07d1408c92767531d9" [language_servers.jdtls] name = "Eclipse JDT Language Server" language = "Java" + +[debug_adapters.Java] +schema_path = "debug_adapter_schemas/Java.json" diff --git a/src/lib.rs b/src/lib.rs index f828e96..30e5e09 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,25 +2,31 @@ use std::{ collections::BTreeSet, env::current_dir, fs::{self, create_dir}, + net::Ipv4Addr, path::{Path, PathBuf}, }; use zed_extension_api::{ - self as zed, CodeLabel, CodeLabelSpan, DownloadedFileType, Extension, LanguageServerId, - LanguageServerInstallationStatus, Os, Worktree, current_platform, download_file, + self as zed, CodeLabel, CodeLabelSpan, DebugAdapterBinary, DebugTaskDefinition, + DownloadedFileType, Extension, LanguageServerId, LanguageServerInstallationStatus, Os, + StartDebuggingRequestArguments, StartDebuggingRequestArgumentsRequest, TcpArguments, Worktree, + current_platform, download_file, http_client::{HttpMethod, HttpRequest, fetch}, lsp::{Completion, CompletionKind}, make_file_executable, register_extension, - serde_json::{self, Value}, + serde_json::{self, Map, Value}, set_language_server_installation_status, settings::LspSettings, }; +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"; struct Java { cached_binary_path: Option, cached_lombok_path: Option, + cached_debugger_path: Option, } impl Java { @@ -272,6 +278,73 @@ impl Java { Ok(jar_path) } + + fn debugger_jar_path(&mut self, language_server_id: &LanguageServerId) -> zed::Result { + if let Some(path) = &self.cached_debugger_path { + if fs::metadata(path).is_ok_and(|stat| stat.is_file()) { + return Ok(path.clone()); + } + } + + set_language_server_installation_status( + language_server_id, + &LanguageServerInstallationStatus::CheckingForUpdate, + ); + + let maven_response_body = serde_json::from_slice::( + &fetch( + &HttpRequest::builder() + .method(HttpMethod::Get) + .url("https://search.maven.org/solrsearch/select?q=a:com.microsoft.java.debug.plugin") + .build()?, + ) + .map_err(|err| format!("failed to fetch Maven: {err}"))? + .body, + ) + .map_err(|err| format!("failed to deserialize Maven response: {err}"))?; + + let latest_version = maven_response_body + .pointer("/response/docs/0/latestVersion") + .map(|v| v.as_str()) + .flatten() + .ok_or("Malformed maven response")?; + + let artifact = maven_response_body + .pointer("/response/docs/0/a") + .map(|v| v.as_str()) + .flatten() + .ok_or("Malformed maven response")?; + + let prefix = "debugger"; + let jar_name = format!("{artifact}-{latest_version}.jar"); + let jar_path = Path::new(prefix).join(&jar_name); + + if !fs::metadata(&jar_path).is_ok_and(|stat| stat.is_file()) { + if let Err(err) = fs::remove_dir_all(prefix) { + println!("failed to remove directory entry: {err}"); + } + + set_language_server_installation_status( + language_server_id, + &LanguageServerInstallationStatus::Downloading, + ); + create_dir(prefix).map_err(|err| err.to_string())?; + + let url = format!( + "https://repo1.maven.org/maven2/com/microsoft/java/{artifact}/{latest_version}/{jar_name}" + ); + + download_file( + url.as_str(), + jar_path.to_str().ok_or(PATH_TO_STR_ERROR)?, + DownloadedFileType::Uncompressed, + ) + .map_err(|err| format!("Failed to download {url} {err}"))?; + } + + self.cached_debugger_path = Some(jar_path.clone()); + Ok(jar_path) + } } impl Extension for Java { @@ -282,6 +355,73 @@ impl Extension for Java { Self { cached_binary_path: None, cached_lombok_path: None, + cached_debugger_path: None, + } + } + + fn get_dap_binary( + &mut self, + adapter_name: String, + config: DebugTaskDefinition, + _user_provided_debug_adapter_path: Option, + worktree: &Worktree, + ) -> zed_extension_api::Result { + if adapter_name != DEBUG_ADAPTER_NAME { + return Err(format!( + "Cannot create binary for adapter \"{adapter_name}\"" + )); + } + + let configuration = config.config.to_string(); + + // We really need to find a better way :) + let port = worktree + .read_text_file("port.txt") + .unwrap() + .parse::() + .unwrap(); + + Ok(DebugAdapterBinary { + command: None, + arguments: vec![], + cwd: Some(worktree.root_path()), + envs: vec![], + request_args: StartDebuggingRequestArguments { + configuration: configuration, + request: StartDebuggingRequestArgumentsRequest::Attach, + }, + connection: Some(TcpArguments { + host: Ipv4Addr::LOCALHOST.to_bits(), + port, + timeout: Some(60_000), + }), + }) + } + + fn dap_request_kind( + &mut self, + adapter_name: String, + config: Value, + ) -> Result { + if adapter_name != DEBUG_ADAPTER_NAME { + return Err(format!( + "Cannot create binary for adapter \"{adapter_name}\"" + )); + } + + match config.get("request") { + Some(launch) if launch == "launch" => { + Ok(zed_extension_api::StartDebuggingRequestArgumentsRequest::Launch) + } + Some(attach) if attach == "attach" => { + Ok(zed_extension_api::StartDebuggingRequestArgumentsRequest::Attach) + } + Some(value) => Err(format!( + "Unexpected value for `request` key in Java debug adapter configuration: {value:?}" + )), + None => { + Err("Missing required `request` field in Java debug adapter configuration".into()) + } } } @@ -290,6 +430,19 @@ impl Extension for Java { language_server_id: &LanguageServerId, worktree: &Worktree, ) -> zed::Result { + let mut current_dir = + current_dir().map_err(|err| format!("could not get current dir: {err}"))?; + + if current_platform().0 == Os::Windows { + current_dir = current_dir + .strip_prefix("/") + .map_err(|err| err.to_string())? + .to_path_buf(); + } + + // download debugger if not exists + self.debugger_jar_path(language_server_id)?; + let configuration = self.language_server_workspace_configuration(language_server_id, worktree)?; let java_home = configuration.as_ref().and_then(|configuration| { @@ -301,6 +454,7 @@ impl Extension for Java { .map(|java_home_str| java_home_str.to_string()) }) }); + let mut env = Vec::new(); if let Some(java_home) = java_home { @@ -308,6 +462,18 @@ impl Extension for Java { } let mut args = Vec::new(); + + args.push("-e".to_string()); + args.push(PROXY_FILE.to_string()); + + args.push( + current_dir + .join(self.language_server_binary_path(language_server_id, worktree)?) + .to_str() + .ok_or(PATH_TO_STR_ERROR)? + .to_string(), + ); + // Add lombok as javaagent if settings.java.jdt.ls.lombokSupport.enabled is true let lombok_enabled = configuration .and_then(|configuration| { @@ -318,16 +484,6 @@ impl Extension for Java { .unwrap_or(false); if lombok_enabled { - let mut current_dir = - current_dir().map_err(|err| format!("could not get current dir: {err}"))?; - - if current_platform().0 == Os::Windows { - current_dir = current_dir - .strip_prefix("/") - .map_err(|err| err.to_string())? - .to_path_buf(); - } - let lombok_jar_path = self.lombok_jar_path(language_server_id)?; let canonical_lombok_jar_path = current_dir .join(lombok_jar_path) @@ -339,11 +495,7 @@ impl Extension for Java { } Ok(zed::Command { - command: self - .language_server_binary_path(language_server_id, worktree)? - .to_str() - .ok_or(PATH_TO_STR_ERROR)? - .to_string(), + command: zed::node_binary_path()?, args, env, }) @@ -354,8 +506,49 @@ impl Extension for Java { language_server_id: &LanguageServerId, worktree: &Worktree, ) -> zed::Result> { - LspSettings::for_worktree(language_server_id.as_ref(), worktree) - .map(|lsp_settings| lsp_settings.initialization_options) + let mut current_dir = + current_dir().map_err(|err| format!("could not get current dir: {err}"))?; + + if current_platform().0 == Os::Windows { + current_dir = current_dir + .strip_prefix("/") + .map_err(|err| err.to_string())? + .to_path_buf(); + } + + let settings = LspSettings::for_worktree(language_server_id.as_ref(), worktree)?; + + let mut initialization_options = settings + .initialization_options + .unwrap_or(Value::Object(Map::new())); + + if let Some(debugger_path) = self.cached_debugger_path.clone() { + // ensure bundles field exists + let mut bundles = initialization_options + .get_mut("bundles") + .unwrap_or(&mut Value::Array(vec![])) + .take(); + + let canonical_path = Value::String( + current_dir + .join(debugger_path) + .to_str() + .ok_or(PATH_TO_STR_ERROR)? + .to_string(), + ); + + let bundles_vec = bundles + .as_array_mut() + .ok_or("Invalid initialization_options format")?; + + if !bundles_vec.contains(&canonical_path) { + bundles_vec.push(canonical_path); + } + + initialization_options["bundles"] = bundles; + } + + Ok(Some(initialization_options)) } fn language_server_workspace_configuration( diff --git a/src/proxy.mjs b/src/proxy.mjs new file mode 100644 index 0000000..20960fa --- /dev/null +++ b/src/proxy.mjs @@ -0,0 +1,174 @@ +import { EventEmitter } from "node:events"; +import { spawn } from "node:child_process"; +import { writeFileSync } from "node:fs"; +import { Transform } from "node:stream"; + +const HEADER_SEPARATOR_BUFFER = Buffer.from("\r\n\r\n"); +const CONTENT_LENGTH_PREFIX_BUFFER = Buffer.from("content-length: "); +const HEADER_SEPARATOR = "\r\n\r\n"; +const CONTENT_LENGTH_PREFIX = "Content-Length: "; + +const bin = process.argv[1]; +const args = process.argv.slice(2); + +const jdtls = spawn(bin, args); + +const proxy = createJsonRpcProxy({ server: jdtls, proxy: process }); + +proxy.on("server", (data, passthrough) => { + passthrough(); +}); + +proxy.on("client", (data, passthrough) => { + passthrough(); +}); + +proxy + .send("workspace/executeCommand", { + command: "vscode.java.startDebugSession", + }) + .then((res) => { + writeFileSync("./port.txt", res.result.toString()); + }); + +export function createJsonRpcProxy({ + server: { stdin: serverStdin, stdout: serverStdout, stderr: serverStderr }, + proxy: { stdin: proxyStdin, stdout: proxyStdout, stderr: proxyStderr }, +}) { + const events = new EventEmitter(); + const queue = new Map(); + const nextid = iterid(); + + proxyStdin.pipe(jsonRpcSeparator()).on("data", (data) => { + events.emit("client", parse(data.toString()), () => + serverStdin.write(data), + ); + }); + + serverStdout.pipe(jsonRpcSeparator()).on("data", (data) => { + const message = parse(data.toString()); + + const pending = queue.get(message?.id); + if (pending) { + pending(message); + queue.delete(message.id); + return; + } + + events.emit("server", message, () => proxyStdout.write(data)); + }); + + serverStderr.pipe(proxyStderr); + + return Object.assign(events, { + /** + * + * @param {'error' | 'warning' | 'info' | 'log'} type + * @param {string} message + */ + log(type, message) { + proxyStdout.write( + JSON.stringify({ + jsonrpc: "2.0", + method: "window/logMessage", + params: { type, message }, + }), + ); + }, + + send(method, params) { + return new Promise((resolve) => { + const id = nextid(); + queue.set(id, resolve); + + serverStdin.write(stringify({ jsonrpc: "2.0", id, method, params })); + }); + }, + }); +} + +function iterid() { + let acc = 1; + return () => "zed-java-proxy-" + acc++; +} + +function jsonRpcSeparator() { + let buffer = Buffer.alloc(0); + let contentLength = null; + + return new Transform({ + transform(chunk, encoding, callback) { + buffer = Buffer.concat([buffer, chunk]); + + while (true) { + const headerEndIndex = buffer.indexOf(HEADER_SEPARATOR_BUFFER); + if (headerEndIndex === -1) { + break; + } + + if (contentLength === null) { + const headersBuffer = buffer.subarray(0, headerEndIndex); + const headers = headersBuffer.toString("utf-8").toLowerCase(); + const lines = headers.split("\r\n"); + let newContentLength = 0; + let foundLength = false; + + for (const line of lines) { + if (line.startsWith(CONTENT_LENGTH_PREFIX_BUFFER.toString())) { + const lengthString = line + .substring(CONTENT_LENGTH_PREFIX_BUFFER.length) + .trim(); + const parsedLength = parseInt(lengthString, 10); + + if (isNaN(parsedLength) || parsedLength < 0) { + this.destroy( + new Error(`Invalid Content-Length header: '${lengthString}'`), + ); + return; + } + + newContentLength = parsedLength; + foundLength = true; + break; + } + } + + if (!foundLength) { + this.destroy(new Error("Missing Content-Length header")); + return; + } + + contentLength = newContentLength; + } + + const headerLength = headerEndIndex + HEADER_SEPARATOR_BUFFER.length; + const totalMessageLength = headerLength + contentLength; + + if (buffer.length < totalMessageLength) { + break; + } + + const fullMessage = buffer.subarray(0, totalMessageLength); + + this.push(fullMessage); + buffer = buffer.subarray(totalMessageLength); + contentLength = null; + } + + callback(); + }, + }); +} + +function stringify(request) { + const json = JSON.stringify(request); + return CONTENT_LENGTH_PREFIX + json.length + HEADER_SEPARATOR + json; +} + +function parse(response) { + try { + return JSON.parse(response.split("\n").at(-1)); + } catch (err) { + return null; + } +} From 906a1d8509aeffe6833f3ab4db88d48e9143f816 Mon Sep 17 00:00:00 2001 From: DeityLamb Date: Thu, 14 Aug 2025 15:42:30 +0300 Subject: [PATCH 2/9] perf: refactor lsp proxy --- src/proxy.mjs | 144 +++++++++++++++++++++++++++++--------------------- 1 file changed, 85 insertions(+), 59 deletions(-) diff --git a/src/proxy.mjs b/src/proxy.mjs index 20960fa..1fa9483 100644 --- a/src/proxy.mjs +++ b/src/proxy.mjs @@ -2,18 +2,19 @@ import { EventEmitter } from "node:events"; import { spawn } from "node:child_process"; import { writeFileSync } from "node:fs"; import { Transform } from "node:stream"; +import { Buffer } from "node:buffer"; -const HEADER_SEPARATOR_BUFFER = Buffer.from("\r\n\r\n"); -const CONTENT_LENGTH_PREFIX_BUFFER = Buffer.from("content-length: "); -const HEADER_SEPARATOR = "\r\n\r\n"; -const CONTENT_LENGTH_PREFIX = "Content-Length: "; +const HEADER_SEPARATOR = Buffer.from("\r\n", "ascii"); +const CONTENT_SEPARATOR = Buffer.from("\r\n\r\n", "ascii"); +const NAME_VALUE_SEPARATOR = Buffer.from(": ", "ascii"); +const CONTENT_LENGTH = "Content-Length"; const bin = process.argv[1]; const args = process.argv.slice(2); const jdtls = spawn(bin, args); -const proxy = createJsonRpcProxy({ server: jdtls, proxy: process }); +const proxy = createLspProxy({ server: jdtls, proxy: process }); proxy.on("server", (data, passthrough) => { passthrough(); @@ -28,10 +29,11 @@ proxy command: "vscode.java.startDebugSession", }) .then((res) => { + proxy.show(3, "Debug session running on port " + res.result); writeFileSync("./port.txt", res.result.toString()); }); -export function createJsonRpcProxy({ +export function createLspProxy({ server: { stdin: serverStdin, stdout: serverStdout, stderr: serverStderr }, proxy: { stdin: proxyStdin, stdout: proxyStdout, stderr: proxyStderr }, }) { @@ -39,13 +41,13 @@ export function createJsonRpcProxy({ const queue = new Map(); const nextid = iterid(); - proxyStdin.pipe(jsonRpcSeparator()).on("data", (data) => { + proxyStdin.pipe(lspMessageSeparator()).on("data", (data) => { events.emit("client", parse(data.toString()), () => serverStdin.write(data), ); }); - serverStdout.pipe(jsonRpcSeparator()).on("data", (data) => { + serverStdout.pipe(lspMessageSeparator()).on("data", (data) => { const message = parse(data.toString()); const pending = queue.get(message?.id); @@ -63,19 +65,26 @@ export function createJsonRpcProxy({ return Object.assign(events, { /** * - * @param {'error' | 'warning' | 'info' | 'log'} type + * @param {1 | 2 | 3 | 4 | 5} type * @param {string} message + * @returns void */ - log(type, message) { + show(type, message) { proxyStdout.write( - JSON.stringify({ + stringify({ jsonrpc: "2.0", - method: "window/logMessage", + method: "window/showMessage", params: { type, message }, }), ); }, + /** + * + * @param {string} method + * @param {any} params + * @returns Promise + */ send(method, params) { return new Promise((resolve) => { const id = nextid(); @@ -92,67 +101,67 @@ function iterid() { return () => "zed-java-proxy-" + acc++; } -function jsonRpcSeparator() { +function lspMessageSeparator() { let buffer = Buffer.alloc(0); let contentLength = null; + let headersLength = null; return new Transform({ transform(chunk, encoding, callback) { buffer = Buffer.concat([buffer, chunk]); + // A single chunk may contain multiple messages while (true) { - const headerEndIndex = buffer.indexOf(HEADER_SEPARATOR_BUFFER); - if (headerEndIndex === -1) { + // Wait until we get the whole headers block + if (buffer.indexOf(CONTENT_SEPARATOR) === -1) { break; } - if (contentLength === null) { - const headersBuffer = buffer.subarray(0, headerEndIndex); - const headers = headersBuffer.toString("utf-8").toLowerCase(); - const lines = headers.split("\r\n"); - let newContentLength = 0; - let foundLength = false; - - for (const line of lines) { - if (line.startsWith(CONTENT_LENGTH_PREFIX_BUFFER.toString())) { - const lengthString = line - .substring(CONTENT_LENGTH_PREFIX_BUFFER.length) - .trim(); - const parsedLength = parseInt(lengthString, 10); - - if (isNaN(parsedLength) || parsedLength < 0) { - this.destroy( - new Error(`Invalid Content-Length header: '${lengthString}'`), - ); - return; - } - - newContentLength = parsedLength; - foundLength = true; - break; - } - } - - if (!foundLength) { - this.destroy(new Error("Missing Content-Length header")); - return; - } - - contentLength = newContentLength; + /** + * The base protocol consists of a header and a content part (comparable to HTTP). + * The header and content part are separated by a ‘\r\n’. + * + * The header part consists of header fields. + * Each header field is comprised of a name and a value, + * separated by ‘: ‘ (a colon and a space). + * The structure of header fields conforms to the HTTP semantic. + * Each header field is terminated by ‘\r\n’. + * Considering the last header field and the overall header + * itself are each terminated with ‘\r\n’, + * and that at least one header is mandatory, + * this means that two ‘\r\n’ sequences always immediately precede + * the content part of a message. + * + * @see {https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#headerPart} + */ + if (!headersLength) { + const headersEnd = buffer.indexOf(CONTENT_SEPARATOR); + const headers = Object.fromEntries( + buffer + .subarray(0, headersEnd) + .toString() + .split(HEADER_SEPARATOR) + .map((header) => header.split(NAME_VALUE_SEPARATOR)) + .map(([name, value]) => [name.toLowerCase(), value]), + ); + + // A "Content-Length" header must always be present + contentLength = parseInt(headers[CONTENT_LENGTH.toLowerCase()], 10); + headersLength = headersEnd + CONTENT_SEPARATOR.length; } - const headerLength = headerEndIndex + HEADER_SEPARATOR_BUFFER.length; - const totalMessageLength = headerLength + contentLength; + const msgLength = headersLength + contentLength; - if (buffer.length < totalMessageLength) { + // Wait until we get the whole content part + if (buffer.length < msgLength) { break; } - const fullMessage = buffer.subarray(0, totalMessageLength); + this.push(buffer.subarray(0, msgLength)); - this.push(fullMessage); - buffer = buffer.subarray(totalMessageLength); + buffer = buffer.subarray(msgLength); contentLength = null; + headersLength = null; } callback(); @@ -160,14 +169,31 @@ function jsonRpcSeparator() { }); } -function stringify(request) { - const json = JSON.stringify(request); - return CONTENT_LENGTH_PREFIX + json.length + HEADER_SEPARATOR + json; +/** + * + * @param {any} content + * @returns {string} + */ +function stringify(content) { + const json = JSON.stringify(content); + return ( + CONTENT_LENGTH + + NAME_VALUE_SEPARATOR + + json.length + + CONTENT_SEPARATOR + + json + ); } -function parse(response) { +/** + * + * @param {string} message + * @returns {any | null} + */ +function parse(message) { try { - return JSON.parse(response.split("\n").at(-1)); + const content = message.slice(message.indexOf(CONTENT_SEPARATOR)); + return JSON.parse(content); } catch (err) { return null; } From c197557763ce6e0f25c012eab4f1a2add95b8c3b Mon Sep 17 00:00:00 2001 From: DeityLamb Date: Thu, 14 Aug 2025 20:42:43 +0300 Subject: [PATCH 3/9] feat: process maven 5xx code gracefully --- src/lib.rs | 65 ++++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 51 insertions(+), 14 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 30e5e09..5eac6bc 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,7 +1,7 @@ use std::{ collections::BTreeSet, env::current_dir, - fs::{self, create_dir}, + fs::{self, create_dir, read_dir}, net::Ipv4Addr, path::{Path, PathBuf}, }; @@ -280,6 +280,8 @@ impl Java { } fn debugger_jar_path(&mut self, language_server_id: &LanguageServerId) -> zed::Result { + let prefix = "debugger"; + if let Some(path) = &self.cached_debugger_path { if fs::metadata(path).is_ok_and(|stat| stat.is_file()) { return Ok(path.clone()); @@ -291,17 +293,53 @@ impl Java { &LanguageServerInstallationStatus::CheckingForUpdate, ); - let maven_response_body = serde_json::from_slice::( - &fetch( - &HttpRequest::builder() - .method(HttpMethod::Get) - .url("https://search.maven.org/solrsearch/select?q=a:com.microsoft.java.debug.plugin") - .build()?, - ) - .map_err(|err| format!("failed to fetch Maven: {err}"))? - .body, - ) - .map_err(|err| format!("failed to deserialize Maven response: {err}"))?; + let res = fetch( + &HttpRequest::builder() + .method(HttpMethod::Get) + .url("https://search.maven.org/solrsearch/select?q=a:com.microsoft.java.debug.plugin") + .build()?, + ); + + // Maven loves to be down, trying to resolve it gracefully + if let Err(err) = &res { + if !fs::metadata(prefix).is_ok_and(|stat| stat.is_dir()) { + return Err(err.to_owned()); + } + + // If it's not a 5xx code, then return an error. + if !err.contains("status code 5") { + return Err(err.to_owned()); + } + + let exists = read_dir(&prefix) + .ok() + .map(|dir| dir.last().map(|v| v.ok())) + .flatten() + .flatten(); + + if let Some(file) = exists { + if !file.metadata().is_ok_and(|stat| stat.is_file()) { + return Err(err.to_owned()); + } + + if !file + .file_name() + .to_str() + .ok_or(PATH_TO_STR_ERROR)? + .ends_with(".jar") + { + return Err(err.to_owned()); + } + + let jar_path = Path::new(prefix).join(file.file_name()); + self.cached_debugger_path = Some(jar_path.clone()); + + return Ok(jar_path); + } + } + + let maven_response_body = serde_json::from_slice::(&res?.body) + .map_err(|err| format!("failed to deserialize Maven response: {err}"))?; let latest_version = maven_response_body .pointer("/response/docs/0/latestVersion") @@ -315,7 +353,6 @@ impl Java { .flatten() .ok_or("Malformed maven response")?; - let prefix = "debugger"; let jar_name = format!("{artifact}-{latest_version}.jar"); let jar_path = Path::new(prefix).join(&jar_name); @@ -388,7 +425,7 @@ impl Extension for Java { envs: vec![], request_args: StartDebuggingRequestArguments { configuration: configuration, - request: StartDebuggingRequestArgumentsRequest::Attach, + request: StartDebuggingRequestArgumentsRequest::Launch, }, connection: Some(TcpArguments { host: Ipv4Addr::LOCALHOST.to_bits(), From f7b6d2cb5ea1c0a41f8ad38bb51a9c4639fb42f2 Mon Sep 17 00:00:00 2001 From: DeityLamb Date: Thu, 14 Aug 2025 22:50:09 +0300 Subject: [PATCH 4/9] wip: debugger schema --- debug_adapter_schemas/Java.json | 222 +++++++++++++++++++++++++++++++- extension.toml | 1 - src/lib.rs | 21 +-- 3 files changed, 232 insertions(+), 12 deletions(-) diff --git a/debug_adapter_schemas/Java.json b/debug_adapter_schemas/Java.json index 0967ef4..f93eda8 100644 --- a/debug_adapter_schemas/Java.json +++ b/debug_adapter_schemas/Java.json @@ -1 +1,221 @@ -{} +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "oneOf": [ + { + "title": "Launch", + "properties": { + "request": { + "type": "string", + "enum": ["launch"], + "description": "The request type for the Java debug adapter, always \"launch\"." + }, + "mainClass": { + "type": "string", + "description": "The fully qualified name of the class containing the main method. If not specified, the debugger automatically resolves the possible main class from current project." + }, + "args": { + "type": "string", + "description": "The command line arguments passed to the program." + }, + "sourcePaths": { + "type": "array", + "items": { + "type": "string" + }, + "description": "The extra source directories of the program. The debugger looks for source code from project settings by default. This option allows the debugger to look for source code in extra directories." + }, + "modulePaths": { + "type": "array", + "items": { + "type": "string" + }, + "description": "The modulepaths for launching the JVM. If not specified, the debugger will automatically resolve from current project. If multiple values are specified, the debugger will merge them together." + }, + "classPaths": { + "type": "array", + "items": { + "type": "string" + }, + "description": "The classpaths for launching the JVM. If not specified, the debugger will automatically resolve from current project. If multiple values are specified, the debugger will merge them together." + }, + "encoding": { + "type": "string", + "description": "The file.encoding setting for the JVM. Possible values can be found in https://docs.oracle.com/javase/8/docs/technotes/guides/intl/encoding.doc.html." + }, + "vmArgs": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ], + "description": "The extra options and system properties for the JVM (e.g. -Xms -Xmx -D=), it accepts a string or an array of string." + }, + "projectName": { + "type": "string", + "description": "The preferred project in which the debugger searches for classes. It is required when the workspace has multiple java projects, otherwise the expression evaluation and conditional breakpoint may not work." + }, + "cwd": { + "type": "string", + "description": "The working directory of the program. Defaults to '${workspaceFolder}'." + }, + "env": { + "type": "object", + "description": "The extra environment variables for the program.", + "additionalProperties": { + "type": "string" + } + }, + "envFile": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ], + "description": "Absolute path to a file containing environment variable definitions. Multiple files can be specified by providing an array of absolute paths." + }, + "stopOnEntry": { + "type": "boolean", + "description": "Automatically pause the program after launching." + }, + "console": { + "type": "string", + "enum": ["internalConsole", "integratedTerminal", "externalTerminal"], + "description": "The specified console to launch the program. If not specified, use the console specified by the java.debug.settings.console user setting." + }, + "shortenCommandLine": { + "type": "string", + "enum": ["none", "jarmanifest", "argfile", "auto"], + "description": "Provides multiple approaches to shorten the command line when it exceeds the maximum command line string limitation allowed by the OS." + }, + "stepFilters": { + "type": "object", + "description": "Skip specified classes or methods when stepping.", + "properties": { + "classNameFilters": { + "type": "array", + "items": { + "type": "string" + }, + "description": "[Deprecated - replaced by 'skipClasses'] Skip the specified classes when stepping. Class names should be fully qualified. Wildcard is supported." + }, + "skipClasses": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Skip the specified classes when stepping." + }, + "skipSynthetics": { + "type": "boolean", + "description": "Skip synthetic methods when stepping." + }, + "skipStaticInitializers": { + "type": "boolean", + "description": "Skip static initializer methods when stepping." + }, + "skipConstructors": { + "type": "boolean", + "description": "Skip constructor methods when stepping." + } + } + }, + "javaExec": { + "type": "string", + "description": "The path to java executable to use. By default, the project JDK's java executable is used." + } + }, + "required": ["request", "mainClass"] + }, + { + "title": "Attach", + "properties": { + "request": { + "type": "string", + "enum": ["attach"], + "description": "The request type for the Java debug adapter, always \"attach\"." + }, + "hostName": { + "type": "string", + "description": "The host name or IP address of remote debuggee." + }, + "port": { + "type": "integer", + "description": "The debug port of remote debuggee." + }, + "processId": { + "type": "string", + "description": "Use process picker to select a process to attach, or Process ID as integer." + }, + "timeout": { + "type": "integer", + "description": "Timeout value before reconnecting, in milliseconds (default to 30000ms)." + }, + "sourcePaths": { + "type": "array", + "items": { + "type": "string" + }, + "description": "The extra source directories of the program. The debugger looks for source code from project settings by default. This option allows the debugger to look for source code in extra directories." + }, + "projectName": { + "type": "string", + "description": "The preferred project in which the debugger searches for classes. It is required when the workspace has multiple java projects, otherwise the expression evaluation and conditional breakpoint may not work." + }, + "stepFilters": { + "type": "object", + "description": "Skip specified classes or methods when stepping.", + "properties": { + "classNameFilters": { + "type": "array", + "items": { + "type": "string" + }, + "description": "[Deprecated - replaced by 'skipClasses'] Skip the specified classes when stepping. Class names should be fully qualified. Wildcard is supported." + }, + "skipClasses": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Skip the specified classes when stepping." + }, + "skipSynthetics": { + "type": "boolean", + "description": "Skip synthetic methods when stepping." + }, + "skipStaticInitializers": { + "type": "boolean", + "description": "Skip static initializer methods when stepping." + }, + "skipConstructors": { + "type": "boolean", + "description": "Skip constructor methods when stepping." + } + } + } + }, + "required": ["request"], + "anyOf": [ + { + "required": ["hostName", "port"] + }, + { + "required": ["processId"] + } + ] + } + ] +} diff --git a/extension.toml b/extension.toml index a8cccaf..61977d6 100644 --- a/extension.toml +++ b/extension.toml @@ -23,4 +23,3 @@ name = "Eclipse JDT Language Server" language = "Java" [debug_adapters.Java] -schema_path = "debug_adapter_schemas/Java.json" diff --git a/src/lib.rs b/src/lib.rs index 5eac6bc..9413f77 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,6 +4,7 @@ use std::{ fs::{self, create_dir, read_dir}, net::Ipv4Addr, path::{Path, PathBuf}, + str::FromStr, }; use zed_extension_api::{ @@ -409,7 +410,7 @@ impl Extension for Java { )); } - let configuration = config.config.to_string(); + dbg!(&config); // We really need to find a better way :) let port = worktree @@ -424,8 +425,12 @@ impl Extension for Java { cwd: Some(worktree.root_path()), envs: vec![], request_args: StartDebuggingRequestArguments { - configuration: configuration, - request: StartDebuggingRequestArgumentsRequest::Launch, + request: self.dap_request_kind( + adapter_name, + Value::from_str(config.config.as_str()) + .map_err(|e| format!("Invalid JSON configuration: {e}"))?, + )?, + configuration: config.config, }, connection: Some(TcpArguments { host: Ipv4Addr::LOCALHOST.to_bits(), @@ -439,7 +444,7 @@ impl Extension for Java { &mut self, adapter_name: String, config: Value, - ) -> Result { + ) -> Result { if adapter_name != DEBUG_ADAPTER_NAME { return Err(format!( "Cannot create binary for adapter \"{adapter_name}\"" @@ -447,12 +452,8 @@ impl Extension for Java { } match config.get("request") { - Some(launch) if launch == "launch" => { - Ok(zed_extension_api::StartDebuggingRequestArgumentsRequest::Launch) - } - Some(attach) if attach == "attach" => { - Ok(zed_extension_api::StartDebuggingRequestArgumentsRequest::Attach) - } + Some(launch) if launch == "launch" => Ok(StartDebuggingRequestArgumentsRequest::Launch), + Some(attach) if attach == "attach" => Ok(StartDebuggingRequestArgumentsRequest::Attach), Some(value) => Err(format!( "Unexpected value for `request` key in Java debug adapter configuration: {value:?}" )), From 38248deeeba37c9c83d46b9b8fd21c07886482bb Mon Sep 17 00:00:00 2001 From: DeityLamb Date: Fri, 15 Aug 2025 17:59:36 +0300 Subject: [PATCH 5/9] feat: setup extension <-> lsp communication --- src/debugger.rs | 203 ++++++++++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 179 ++++-------------------------------------- src/lsp.rs | 63 +++++++++++++++ src/proxy.mjs | 182 +++++++++++++++++++++++++++++-------------- 4 files changed, 405 insertions(+), 222 deletions(-) create mode 100644 src/debugger.rs create mode 100644 src/lsp.rs diff --git a/src/debugger.rs b/src/debugger.rs new file mode 100644 index 0000000..07cd6d8 --- /dev/null +++ b/src/debugger.rs @@ -0,0 +1,203 @@ +use std::{env::current_dir, fs, net::Ipv4Addr, path::PathBuf}; + +use zed_extension_api::{ + self as zed, DownloadedFileType, LanguageServerId, LanguageServerInstallationStatus, Os, + TcpArguments, Worktree, current_platform, download_file, + http_client::{HttpMethod, HttpRequest, fetch}, + serde_json::{self, Value, json}, + set_language_server_installation_status, +}; + +use crate::lsp::LspClient; + +const MAVEN_SEARCH_URL: &str = + "https://search.maven.org/solrsearch/select?q=a:com.microsoft.java.debug.plugin"; + +pub struct Debugger { + path: Option, +} + +impl Debugger { + pub fn new() -> Debugger { + Debugger { path: None } + } + + pub fn get_or_download( + &mut self, + language_server_id: &LanguageServerId, + ) -> zed::Result { + let prefix = "debugger"; + + if let Some(path) = &self.path { + if fs::metadata(path).is_ok_and(|stat| stat.is_file()) { + return Ok(path.clone()); + } + } + + set_language_server_installation_status( + language_server_id, + &LanguageServerInstallationStatus::CheckingForUpdate, + ); + + let res = fetch( + &HttpRequest::builder() + .method(HttpMethod::Get) + .url(MAVEN_SEARCH_URL) + .build()?, + ); + + // Maven loves to be down, trying to resolve it gracefully + if let Err(err) = &res { + if !fs::metadata(prefix).is_ok_and(|stat| stat.is_dir()) { + return Err(err.to_owned()); + } + + // If it's not a 5xx code, then return an error. + if !err.contains("status code 5") { + return Err(err.to_owned()); + } + + let exists = fs::read_dir(&prefix) + .ok() + .map(|dir| dir.last().map(|v| v.ok())) + .flatten() + .flatten(); + + if let Some(file) = exists { + if !file.metadata().is_ok_and(|stat| stat.is_file()) { + return Err(err.to_owned()); + } + + if !file + .file_name() + .to_str() + .is_some_and(|name| name.ends_with(".jar")) + { + return Err(err.to_owned()); + } + + let jar_path = PathBuf::from(prefix).join(file.file_name()); + self.path = Some(jar_path.clone()); + + return Ok(jar_path); + } + } + + let maven_response_body = serde_json::from_slice::(&res?.body) + .map_err(|err| format!("failed to deserialize Maven response: {err}"))?; + + let latest_version = maven_response_body + .pointer("/response/docs/0/latestVersion") + .map(|v| v.as_str()) + .flatten() + .ok_or("Malformed maven response")?; + + let artifact = maven_response_body + .pointer("/response/docs/0/a") + .map(|v| v.as_str()) + .flatten() + .ok_or("Malformed maven response")?; + + let jar_name = format!("{artifact}-{latest_version}.jar"); + let jar_path = PathBuf::from(prefix).join(&jar_name); + + if !fs::metadata(&jar_path).is_ok_and(|stat| stat.is_file()) { + if let Err(err) = fs::remove_dir_all(prefix) { + println!("failed to remove directory entry: {err}"); + } + + set_language_server_installation_status( + language_server_id, + &LanguageServerInstallationStatus::Downloading, + ); + fs::create_dir(prefix).map_err(|err| err.to_string())?; + + let url = format!( + "https://repo1.maven.org/maven2/com/microsoft/java/{artifact}/{latest_version}/{jar_name}" + ); + + download_file( + url.as_str(), + jar_path + .to_str() + .ok_or("Failed to convert path to string")?, + DownloadedFileType::Uncompressed, + ) + .map_err(|err| format!("Failed to download {url} {err}"))?; + } + + self.path = Some(jar_path.clone()); + Ok(jar_path) + } + + pub fn start_session(&self, worktree: &Worktree) -> zed::Result { + let port = LspClient::request( + worktree, + "workspace/executeCommand", + json!({ + "command": "vscode.java.startDebugSession" + }), + )? + .get("result") + .map(|v| v.as_u64()) + .flatten() + .ok_or("Failed to read lsp proxy debug response")?; + + Ok(TcpArguments { + host: Ipv4Addr::LOCALHOST.to_bits(), + port: port as u16, + timeout: Some(60_000), + }) + } + + pub fn inject_plugin_into_options( + &self, + initialization_options: Option, + ) -> zed::Result { + let mut current_dir = + current_dir().map_err(|err| format!("could not get current dir: {err}"))?; + + if current_platform().0 == Os::Windows { + current_dir = current_dir + .strip_prefix("/") + .map_err(|err| err.to_string())? + .to_path_buf(); + } + + let canonical_path = Value::String( + current_dir + .join(self.path.as_ref().ok_or("Debugger is not loaded yet")?) + .to_string_lossy() + .to_string(), + ); + + match initialization_options { + None => { + return Ok(json!({ + "bundles": [canonical_path] + })); + } + Some(options) => { + let mut options = options.clone(); + + // ensure bundles field exists + let mut bundles = options + .get_mut("bundles") + .unwrap_or(&mut Value::Array(vec![])) + .take(); + + let bundles_vec = bundles + .as_array_mut() + .ok_or("Invalid initialization_options format")?; + + if !bundles_vec.contains(&canonical_path) { + bundles_vec.push(canonical_path); + } + + options["bundles"] = bundles; + + return Ok(options); + } + } + } +} diff --git a/src/lib.rs b/src/lib.rs index 9413f77..3074d74 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,6 +1,8 @@ +mod debugger; +mod lsp; use std::{ collections::BTreeSet, - env::current_dir, + env::{self, current_dir}, fs::{self, create_dir, read_dir}, net::Ipv4Addr, path::{Path, PathBuf}, @@ -15,11 +17,13 @@ use zed_extension_api::{ http_client::{HttpMethod, HttpRequest, fetch}, lsp::{Completion, CompletionKind}, make_file_executable, register_extension, - serde_json::{self, Map, Value}, + serde_json::{self, Map, Value, json}, set_language_server_installation_status, settings::LspSettings, }; +use crate::{debugger::Debugger, lsp::LspClient}; + 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"; @@ -27,7 +31,7 @@ const PATH_TO_STR_ERROR: &str = "failed to convert path to string"; struct Java { cached_binary_path: Option, cached_lombok_path: Option, - cached_debugger_path: Option, + debugger: Debugger, } impl Java { @@ -279,110 +283,6 @@ impl Java { Ok(jar_path) } - - fn debugger_jar_path(&mut self, language_server_id: &LanguageServerId) -> zed::Result { - let prefix = "debugger"; - - if let Some(path) = &self.cached_debugger_path { - if fs::metadata(path).is_ok_and(|stat| stat.is_file()) { - return Ok(path.clone()); - } - } - - set_language_server_installation_status( - language_server_id, - &LanguageServerInstallationStatus::CheckingForUpdate, - ); - - let res = fetch( - &HttpRequest::builder() - .method(HttpMethod::Get) - .url("https://search.maven.org/solrsearch/select?q=a:com.microsoft.java.debug.plugin") - .build()?, - ); - - // Maven loves to be down, trying to resolve it gracefully - if let Err(err) = &res { - if !fs::metadata(prefix).is_ok_and(|stat| stat.is_dir()) { - return Err(err.to_owned()); - } - - // If it's not a 5xx code, then return an error. - if !err.contains("status code 5") { - return Err(err.to_owned()); - } - - let exists = read_dir(&prefix) - .ok() - .map(|dir| dir.last().map(|v| v.ok())) - .flatten() - .flatten(); - - if let Some(file) = exists { - if !file.metadata().is_ok_and(|stat| stat.is_file()) { - return Err(err.to_owned()); - } - - if !file - .file_name() - .to_str() - .ok_or(PATH_TO_STR_ERROR)? - .ends_with(".jar") - { - return Err(err.to_owned()); - } - - let jar_path = Path::new(prefix).join(file.file_name()); - self.cached_debugger_path = Some(jar_path.clone()); - - return Ok(jar_path); - } - } - - let maven_response_body = serde_json::from_slice::(&res?.body) - .map_err(|err| format!("failed to deserialize Maven response: {err}"))?; - - let latest_version = maven_response_body - .pointer("/response/docs/0/latestVersion") - .map(|v| v.as_str()) - .flatten() - .ok_or("Malformed maven response")?; - - let artifact = maven_response_body - .pointer("/response/docs/0/a") - .map(|v| v.as_str()) - .flatten() - .ok_or("Malformed maven response")?; - - let jar_name = format!("{artifact}-{latest_version}.jar"); - let jar_path = Path::new(prefix).join(&jar_name); - - if !fs::metadata(&jar_path).is_ok_and(|stat| stat.is_file()) { - if let Err(err) = fs::remove_dir_all(prefix) { - println!("failed to remove directory entry: {err}"); - } - - set_language_server_installation_status( - language_server_id, - &LanguageServerInstallationStatus::Downloading, - ); - create_dir(prefix).map_err(|err| err.to_string())?; - - let url = format!( - "https://repo1.maven.org/maven2/com/microsoft/java/{artifact}/{latest_version}/{jar_name}" - ); - - download_file( - url.as_str(), - jar_path.to_str().ok_or(PATH_TO_STR_ERROR)?, - DownloadedFileType::Uncompressed, - ) - .map_err(|err| format!("Failed to download {url} {err}"))?; - } - - self.cached_debugger_path = Some(jar_path.clone()); - Ok(jar_path) - } } impl Extension for Java { @@ -393,7 +293,7 @@ impl Extension for Java { Self { cached_binary_path: None, cached_lombok_path: None, - cached_debugger_path: None, + debugger: Debugger::new(), } } @@ -410,15 +310,6 @@ impl Extension for Java { )); } - dbg!(&config); - - // We really need to find a better way :) - let port = worktree - .read_text_file("port.txt") - .unwrap() - .parse::() - .unwrap(); - Ok(DebugAdapterBinary { command: None, arguments: vec![], @@ -432,11 +323,7 @@ impl Extension for Java { )?, configuration: config.config, }, - connection: Some(TcpArguments { - host: Ipv4Addr::LOCALHOST.to_bits(), - port, - timeout: Some(60_000), - }), + connection: Some(self.debugger.start_session(worktree)?), }) } @@ -479,7 +366,7 @@ impl Extension for Java { } // download debugger if not exists - self.debugger_jar_path(language_server_id)?; + self.debugger.get_or_download(language_server_id)?; let configuration = self.language_server_workspace_configuration(language_server_id, worktree)?; @@ -503,6 +390,7 @@ impl Extension for Java { args.push("-e".to_string()); args.push(PROXY_FILE.to_string()); + args.push(current_dir.to_str().ok_or(PATH_TO_STR_ERROR)?.to_string()); args.push( current_dir @@ -544,49 +432,10 @@ impl Extension for Java { language_server_id: &LanguageServerId, worktree: &Worktree, ) -> zed::Result> { - let mut current_dir = - current_dir().map_err(|err| format!("could not get current dir: {err}"))?; - - if current_platform().0 == Os::Windows { - current_dir = current_dir - .strip_prefix("/") - .map_err(|err| err.to_string())? - .to_path_buf(); - } - - let settings = LspSettings::for_worktree(language_server_id.as_ref(), worktree)?; - - let mut initialization_options = settings - .initialization_options - .unwrap_or(Value::Object(Map::new())); - - if let Some(debugger_path) = self.cached_debugger_path.clone() { - // ensure bundles field exists - let mut bundles = initialization_options - .get_mut("bundles") - .unwrap_or(&mut Value::Array(vec![])) - .take(); - - let canonical_path = Value::String( - current_dir - .join(debugger_path) - .to_str() - .ok_or(PATH_TO_STR_ERROR)? - .to_string(), - ); - - let bundles_vec = bundles - .as_array_mut() - .ok_or("Invalid initialization_options format")?; - - if !bundles_vec.contains(&canonical_path) { - bundles_vec.push(canonical_path); - } - - initialization_options["bundles"] = bundles; - } + let options = LspSettings::for_worktree(language_server_id.as_ref(), worktree) + .map(|lsp_settings| lsp_settings.initialization_options)?; - Ok(Some(initialization_options)) + Ok(Some(self.debugger.inject_plugin_into_options(options)?)) } fn language_server_workspace_configuration( diff --git a/src/lsp.rs b/src/lsp.rs new file mode 100644 index 0000000..a0bd747 --- /dev/null +++ b/src/lsp.rs @@ -0,0 +1,63 @@ +use std::{ + fs::{self}, + path::Path, +}; + +use zed_extension_api::{ + Worktree, + http_client::{HttpMethod, HttpRequest, fetch}, + 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. +*/ +pub struct LspClient {} + +impl LspClient { + pub fn request(worktree: &Worktree, method: &str, params: Value) -> Result { + // We cannot cache it because the user may restart the LSP + let port = { + let filename = string_to_hex(worktree.root_path().as_str()); + + let port_path = Path::new("proxy").join(filename); + + if !fs::metadata(&port_path).is_ok_and(|file| file.is_file()) { + return Err("Lsp proxy is not running".to_owned()); + } + + fs::read_to_string(port_path) + .map_err(|e| format!("Failed to read a lsp proxy port from file {e}"))? + .parse::() + .map_err(|e| format!("Failed to read a lsp proxy port, file corrupted {e}"))? + }; + + let mut body = Map::new(); + body.insert("method".to_owned(), Value::String(method.to_owned())); + body.insert("params".to_owned(), params); + + let res = fetch( + &HttpRequest::builder() + .method(HttpMethod::Post) + .url(format!("http://localhost:{port}")) + .body(Value::Object(body).to_string()) + .build()?, + ) + .map_err(|e| format!("Failed to send request to lsp proxy {e}"))?; + + Ok(serde_json::from_slice(&res.body) + .map_err(|e| format!("Failed to parse response from lsp proxy {e}"))?) + } +} +fn string_to_hex(s: &str) -> String { + let mut hex_string = String::new(); + for byte in s.as_bytes() { + hex_string.push_str(&format!("{:02x}", byte)); + } + hex_string +} diff --git a/src/proxy.mjs b/src/proxy.mjs index 1fa9483..b0ca331 100644 --- a/src/proxy.mjs +++ b/src/proxy.mjs @@ -1,37 +1,77 @@ -import { EventEmitter } from "node:events"; +import { Buffer } from "node:buffer"; import { spawn } from "node:child_process"; -import { writeFileSync } from "node:fs"; +import { EventEmitter } from "node:events"; +import { + existsSync, + mkdirSync, + readdirSync, + unlinkSync, + writeFileSync, +} from "node:fs"; +import { createServer } from "node:http"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; import { Transform } from "node:stream"; -import { Buffer } from "node:buffer"; +import { text } from "node:stream/consumers"; +const HTTP_PORT = 0; // 0 - random free one const HEADER_SEPARATOR = Buffer.from("\r\n", "ascii"); const CONTENT_SEPARATOR = Buffer.from("\r\n\r\n", "ascii"); const NAME_VALUE_SEPARATOR = Buffer.from(": ", "ascii"); -const CONTENT_LENGTH = "Content-Length"; +const LENGTH_HEADER = "Content-Length"; +const TIMEOUT = 5_000; -const bin = process.argv[1]; -const args = process.argv.slice(2); +const workdir = process.argv[1]; +const bin = process.argv[2]; +const args = process.argv.slice(3); -const jdtls = spawn(bin, args); +const PROXY_ID = Buffer.from(process.cwd().replace(/\/+$/, "")).toString("hex"); +const PROXY_HTTP_PORT_FILE = join(workdir, "proxy", PROXY_ID); -const proxy = createLspProxy({ server: jdtls, proxy: process }); +const lsp = spawn(bin, args); +const proxy = createLspProxy({ server: lsp, proxy: process }); -proxy.on("server", (data, passthrough) => { +proxy.on("client", (data, passthrough) => { passthrough(); }); - -proxy.on("client", (data, passthrough) => { +proxy.on("server", (data, passthrough) => { passthrough(); }); -proxy - .send("workspace/executeCommand", { - command: "vscode.java.startDebugSession", - }) - .then((res) => { - proxy.show(3, "Debug session running on port " + res.result); - writeFileSync("./port.txt", res.result.toString()); - }); +const server = createServer(async (req, res) => { + if (req.method !== "POST") { + res.status = 405; + res.end("Method not allowed"); + return; + } + + const data = await text(req) + .then(safeJsonParse) + .catch(() => null); + + if (!data) { + res.status = 400; + res.end("Bad Request"); + return; + } + + const result = await proxy.request(data.method, data.params); + res.statusCode = 200; + res.setHeader("Content-Type", "application/json"); + res.write(JSON.stringify(result)); + res.end(); +}).listen(HTTP_PORT, () => { + const dir = dirname(PROXY_HTTP_PORT_FILE); + + if (existsSync(dir)) { + for (const file of readdirSync(dir)) { + unlinkSync(join(dir, file)); + } + } + + mkdirSync(dir, { recursive: true }); + writeFileSync(PROXY_HTTP_PORT_FILE, server.address().port.toString()); +}); export function createLspProxy({ server: { stdin: serverStdin, stdout: serverStdout, stderr: serverStderr }, @@ -42,13 +82,11 @@ export function createLspProxy({ const nextid = iterid(); proxyStdin.pipe(lspMessageSeparator()).on("data", (data) => { - events.emit("client", parse(data.toString()), () => - serverStdin.write(data), - ); + events.emit("client", parse(data), () => serverStdin.write(data)); }); serverStdout.pipe(lspMessageSeparator()).on("data", (data) => { - const message = parse(data.toString()); + const message = parse(data); const pending = queue.get(message?.id); if (pending) { @@ -65,18 +103,12 @@ export function createLspProxy({ return Object.assign(events, { /** * - * @param {1 | 2 | 3 | 4 | 5} type - * @param {string} message + * @param {string} method + * @param {any} params * @returns void */ - show(type, message) { - proxyStdout.write( - stringify({ - jsonrpc: "2.0", - method: "window/showMessage", - params: { type, message }, - }), - ); + notification(method, params) { + proxyStdout.write(stringify({ jsonrpc: "2.0", method, params })); }, /** @@ -85,22 +117,66 @@ export function createLspProxy({ * @param {any} params * @returns Promise */ - send(method, params) { - return new Promise((resolve) => { + request(method, params) { + return new Promise((resolve, reject) => { const id = nextid(); queue.set(id, resolve); + setTimeout(() => { + if (queue.has(id)) { + reject({ + jsonrpc: "2.0", + id, + error: { + code: -32803, + message: "Request to language server timed out after 5000ms.", + }, + }); + this.cancel(id); + } + }, TIMEOUT); + serverStdin.write(stringify({ jsonrpc: "2.0", id, method, params })); }); }, + + cancel(id) { + queue.delete(id); + + serverStdin.write( + stringify({ + jsonrpc: "2.0", + method: "$/cancelRequest", + params: { id }, + }), + ); + }, }); } function iterid() { let acc = 1; - return () => "zed-java-proxy-" + acc++; + return () => PROXY_ID + "-" + acc++; } +/** + * The base protocol consists of a header and a content part (comparable to HTTP). + * The header and content part are separated by a ‘\r\n’. + * + * The header part consists of header fields. + * Each header field is comprised of a name and a value, + * separated by ‘: ‘ (a colon and a space). + * The structure of header fields conforms to the HTTP semantic. + * Each header field is terminated by ‘\r\n’. + * Considering the last header field and the overall header + * itself are each terminated with ‘\r\n’, + * and that at least one header is mandatory, + * this means that two ‘\r\n’ sequences always immediately precede + * the content part of a message. + * + * @returns {Transform} + * @see [language-server-protocol](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#headerPart) + */ function lspMessageSeparator() { let buffer = Buffer.alloc(0); let contentLength = null; @@ -117,23 +193,6 @@ function lspMessageSeparator() { break; } - /** - * The base protocol consists of a header and a content part (comparable to HTTP). - * The header and content part are separated by a ‘\r\n’. - * - * The header part consists of header fields. - * Each header field is comprised of a name and a value, - * separated by ‘: ‘ (a colon and a space). - * The structure of header fields conforms to the HTTP semantic. - * Each header field is terminated by ‘\r\n’. - * Considering the last header field and the overall header - * itself are each terminated with ‘\r\n’, - * and that at least one header is mandatory, - * this means that two ‘\r\n’ sequences always immediately precede - * the content part of a message. - * - * @see {https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#headerPart} - */ if (!headersLength) { const headersEnd = buffer.indexOf(CONTENT_SEPARATOR); const headers = Object.fromEntries( @@ -146,7 +205,7 @@ function lspMessageSeparator() { ); // A "Content-Length" header must always be present - contentLength = parseInt(headers[CONTENT_LENGTH.toLowerCase()], 10); + contentLength = parseInt(headers[LENGTH_HEADER.toLowerCase()], 10); headersLength = headersEnd + CONTENT_SEPARATOR.length; } @@ -177,7 +236,7 @@ function lspMessageSeparator() { function stringify(content) { const json = JSON.stringify(content); return ( - CONTENT_LENGTH + + LENGTH_HEADER + NAME_VALUE_SEPARATOR + json.length + CONTENT_SEPARATOR + @@ -191,9 +250,18 @@ function stringify(content) { * @returns {any | null} */ function parse(message) { + const content = message.slice(message.indexOf(CONTENT_SEPARATOR)); + return safeJsonParse(content); +} + +/** + * + * @param {string} json + * @returns {any | null} + */ +function safeJsonParse(json) { try { - const content = message.slice(message.indexOf(CONTENT_SEPARATOR)); - return JSON.parse(content); + return JSON.parse(json); } catch (err) { return null; } From ea6980571e06239379fbd57fc52b5503b540c017 Mon Sep 17 00:00:00 2001 From: DeityLamb Date: Sat, 16 Aug 2025 15:51:26 +0300 Subject: [PATCH 6/9] feat: autoresolve debug config --- Cargo.toml | 2 + debug_adapter_schemas/Java.json | 156 +++++------------------- languages/java/config.toml | 1 + src/debugger.rs | 205 +++++++++++++++++++++++++++----- src/lib.rs | 81 +++++++++++-- src/lsp.rs | 74 ++++++++++-- 6 files changed, 343 insertions(+), 176 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index ba505ea..f14fc05 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,4 +7,6 @@ edition = "2024" crate-type = ["cdylib"] [dependencies] +serde = { version = "1.0.219", features = ["derive"] } +serde_json = "1.0.140" zed_extension_api = "0.6.0" diff --git a/debug_adapter_schemas/Java.json b/debug_adapter_schemas/Java.json index f93eda8..73a9f1a 100644 --- a/debug_adapter_schemas/Java.json +++ b/debug_adapter_schemas/Java.json @@ -10,56 +10,39 @@ "enum": ["launch"], "description": "The request type for the Java debug adapter, always \"launch\"." }, + "projectName": { + "type": "string", + "description": "The fully qualified name of the project" + }, "mainClass": { "type": "string", - "description": "The fully qualified name of the class containing the main method. If not specified, the debugger automatically resolves the possible main class from current project." + "description": "The fully qualified name of the class containing the main method. If not specified, the debugger automatically resolves the possible main class from the current project." }, "args": { "type": "string", "description": "The command line arguments passed to the program." }, - "sourcePaths": { - "type": "array", - "items": { - "type": "string" - }, - "description": "The extra source directories of the program. The debugger looks for source code from project settings by default. This option allows the debugger to look for source code in extra directories." + "vmArgs": { + "type": "string", + "description": "The extra options and system properties for the JVM (e.g., -Xms -Xmx -D=). It accepts a string or an array of strings." }, - "modulePaths": { + "encoding": { + "type": "string", + "description": "The file.encoding setting for the JVM. Possible values can be found in the Supported Encodings documentation." + }, + "classPaths": { "type": "array", "items": { "type": "string" }, - "description": "The modulepaths for launching the JVM. If not specified, the debugger will automatically resolve from current project. If multiple values are specified, the debugger will merge them together." + "description": "The classpaths for launching the JVM. If not specified, the debugger will automatically resolve them from the current project. If multiple values are specified, the debugger will merge them together. Available values for special handling include: '$Auto' - Automatically resolve the classpaths of the current project. '$Runtime' - The classpaths within the 'runtime' scope of the current project. '$Test' - The classpaths within the 'test' scope of the current project." }, - "classPaths": { + "modulePaths": { "type": "array", "items": { "type": "string" }, - "description": "The classpaths for launching the JVM. If not specified, the debugger will automatically resolve from current project. If multiple values are specified, the debugger will merge them together." - }, - "encoding": { - "type": "string", - "description": "The file.encoding setting for the JVM. Possible values can be found in https://docs.oracle.com/javase/8/docs/technotes/guides/intl/encoding.doc.html." - }, - "vmArgs": { - "oneOf": [ - { - "type": "string" - }, - { - "type": "array", - "items": { - "type": "string" - } - } - ], - "description": "The extra options and system properties for the JVM (e.g. -Xms -Xmx -D=), it accepts a string or an array of string." - }, - "projectName": { - "type": "string", - "description": "The preferred project in which the debugger searches for classes. It is required when the workspace has multiple java projects, otherwise the expression evaluation and conditional breakpoint may not work." + "description": "The modulepaths for launching the JVM. If not specified, the debugger will automatically resolve them from the current project. If multiple values are specified, the debugger will merge them together." }, "cwd": { "type": "string", @@ -72,72 +55,34 @@ "type": "string" } }, - "envFile": { - "oneOf": [ - { - "type": "string" - }, - { - "type": "array", - "items": { - "type": "string" - } - } - ], - "description": "Absolute path to a file containing environment variable definitions. Multiple files can be specified by providing an array of absolute paths." - }, "stopOnEntry": { "type": "boolean", "description": "Automatically pause the program after launching." }, + "noDebug": { + "type": "boolean", + "description": "If set to 'true', disables debugging. The program will be launched without attaching the debugger. Useful for launching a program without debugging, for instance for profiling." + }, "console": { "type": "string", "enum": ["internalConsole", "integratedTerminal", "externalTerminal"], - "description": "The specified console to launch the program. If not specified, use the console specified by the java.debug.settings.console user setting." + "description": "The specified console to launch the program. If not specified, use the console specified by the 'java.debug.settings.console' user setting." }, "shortenCommandLine": { "type": "string", "enum": ["none", "jarmanifest", "argfile", "auto"], "description": "Provides multiple approaches to shorten the command line when it exceeds the maximum command line string limitation allowed by the OS." }, - "stepFilters": { - "type": "object", - "description": "Skip specified classes or methods when stepping.", - "properties": { - "classNameFilters": { - "type": "array", - "items": { - "type": "string" - }, - "description": "[Deprecated - replaced by 'skipClasses'] Skip the specified classes when stepping. Class names should be fully qualified. Wildcard is supported." - }, - "skipClasses": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Skip the specified classes when stepping." - }, - "skipSynthetics": { - "type": "boolean", - "description": "Skip synthetic methods when stepping." - }, - "skipStaticInitializers": { - "type": "boolean", - "description": "Skip static initializer methods when stepping." - }, - "skipConstructors": { - "type": "boolean", - "description": "Skip constructor methods when stepping." - } - } + "launcherScript": { + "type": "string", + "description": "The path to an external launcher script to use instead of the debugger's built-in launcher. This is an advanced option for customizing how the JVM is launched." }, "javaExec": { "type": "string", - "description": "The path to java executable to use. By default, the project JDK's java executable is used." + "description": "The path to the Java executable to use. By default, the project JDK's Java executable is used." } }, - "required": ["request", "mainClass"] + "required": ["request"] }, { "title": "Attach", @@ -149,62 +94,19 @@ }, "hostName": { "type": "string", - "description": "The host name or IP address of remote debuggee." + "description": "The host name or IP address of the remote debuggee." }, "port": { "type": "integer", - "description": "The debug port of remote debuggee." + "description": "The debug port of the remote debuggee." }, "processId": { "type": "string", - "description": "Use process picker to select a process to attach, or Process ID as integer." + "description": "Use a process picker to select a process to attach, or provide a Process ID as an integer. Use '${command:PickJavaProcess}' to prompt the user to select a Java process." }, "timeout": { "type": "integer", "description": "Timeout value before reconnecting, in milliseconds (default to 30000ms)." - }, - "sourcePaths": { - "type": "array", - "items": { - "type": "string" - }, - "description": "The extra source directories of the program. The debugger looks for source code from project settings by default. This option allows the debugger to look for source code in extra directories." - }, - "projectName": { - "type": "string", - "description": "The preferred project in which the debugger searches for classes. It is required when the workspace has multiple java projects, otherwise the expression evaluation and conditional breakpoint may not work." - }, - "stepFilters": { - "type": "object", - "description": "Skip specified classes or methods when stepping.", - "properties": { - "classNameFilters": { - "type": "array", - "items": { - "type": "string" - }, - "description": "[Deprecated - replaced by 'skipClasses'] Skip the specified classes when stepping. Class names should be fully qualified. Wildcard is supported." - }, - "skipClasses": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Skip the specified classes when stepping." - }, - "skipSynthetics": { - "type": "boolean", - "description": "Skip synthetic methods when stepping." - }, - "skipStaticInitializers": { - "type": "boolean", - "description": "Skip static initializer methods when stepping." - }, - "skipConstructors": { - "type": "boolean", - "description": "Skip constructor methods when stepping." - } - } } }, "required": ["request"], diff --git a/languages/java/config.toml b/languages/java/config.toml index d5f8981..a42025a 100644 --- a/languages/java/config.toml +++ b/languages/java/config.toml @@ -16,3 +16,4 @@ collapsed_placeholder = " /* ... */ " documentation = { start = "/*", end = "*/", prefix = "* ", tab_size = 1 } prettier_parser_name = "java" prettier_plugins = ["prettier-plugin-java"] +debuggers = ["Java"] \ No newline at end of file diff --git a/src/debugger.rs b/src/debugger.rs index 07cd6d8..c8e4b2a 100644 --- a/src/debugger.rs +++ b/src/debugger.rs @@ -1,8 +1,10 @@ -use std::{env::current_dir, fs, net::Ipv4Addr, path::PathBuf}; +use std::{collections::HashMap, env::current_dir, fs, path::PathBuf, sync::Arc}; +use serde::{Deserialize, Serialize}; +use serde_json::Map; use zed_extension_api::{ self as zed, DownloadedFileType, LanguageServerId, LanguageServerInstallationStatus, Os, - TcpArguments, Worktree, current_platform, download_file, + TcpArgumentsTemplate, Worktree, current_platform, download_file, http_client::{HttpMethod, HttpRequest, fetch}, serde_json::{self, Value, json}, set_language_server_installation_status, @@ -10,16 +12,64 @@ use zed_extension_api::{ use crate::lsp::LspClient; +#[derive(Serialize, Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +struct JavaDebugLaunchConfig { + request: String, + #[serde(skip_serializing_if = "Option::is_none")] + project_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + main_class: Option, + #[serde(skip_serializing_if = "Option::is_none")] + args: Option, + #[serde(skip_serializing_if = "Option::is_none")] + vm_args: Option, + #[serde(skip_serializing_if = "Option::is_none")] + encoding: Option, + #[serde(skip_serializing_if = "Option::is_none")] + class_paths: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + module_paths: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + cwd: Option, + #[serde(skip_serializing_if = "Option::is_none")] + env: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + stop_on_entry: Option, + #[serde(skip_serializing_if = "Option::is_none")] + no_debug: Option, + #[serde(skip_serializing_if = "Option::is_none")] + console: Option, + #[serde(skip_serializing_if = "Option::is_none")] + shorten_command_line: Option, + #[serde(skip_serializing_if = "Option::is_none")] + launcher_script: Option, + #[serde(skip_serializing_if = "Option::is_none")] + java_exec: Option, +} + +const TEST_SCOPE: &str = "$Test"; +const AUTO_SCOPE: &str = "$Auto"; +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 MAVEN_SEARCH_URL: &str = "https://search.maven.org/solrsearch/select?q=a:com.microsoft.java.debug.plugin"; pub struct Debugger { - path: Option, + lsp: Arc, + plugin_path: Option, } impl Debugger { - pub fn new() -> Debugger { - Debugger { path: None } + pub fn new(lsp: Arc) -> Debugger { + Debugger { + plugin_path: None, + lsp, + } } pub fn get_or_download( @@ -28,7 +78,7 @@ impl Debugger { ) -> zed::Result { let prefix = "debugger"; - if let Some(path) = &self.path { + if let Some(path) = &self.plugin_path { if fs::metadata(path).is_ok_and(|stat| stat.is_file()) { return Ok(path.clone()); } @@ -77,7 +127,7 @@ impl Debugger { } let jar_path = PathBuf::from(prefix).join(file.file_name()); - self.path = Some(jar_path.clone()); + self.plugin_path = Some(jar_path.clone()); return Ok(jar_path); } @@ -118,38 +168,132 @@ impl Debugger { download_file( url.as_str(), - jar_path - .to_str() - .ok_or("Failed to convert path to string")?, + jar_path.to_str().ok_or(PATH_TO_STR_ERROR)?, DownloadedFileType::Uncompressed, ) .map_err(|err| format!("Failed to download {url} {err}"))?; } - self.path = Some(jar_path.clone()); + self.plugin_path = Some(jar_path.clone()); Ok(jar_path) } - pub fn start_session(&self, worktree: &Worktree) -> zed::Result { - let port = LspClient::request( - worktree, + pub fn start_session(&self) -> zed::Result { + let port = self.lsp.request::( "workspace/executeCommand", - json!({ - "command": "vscode.java.startDebugSession" - }), - )? - .get("result") - .map(|v| v.as_u64()) - .flatten() - .ok_or("Failed to read lsp proxy debug response")?; - - Ok(TcpArguments { - host: Ipv4Addr::LOCALHOST.to_bits(), - port: port as u16, - timeout: Some(60_000), + json!({ "command": "vscode.java.startDebugSession" }), + )?; + + Ok(TcpArgumentsTemplate { + host: None, + port: Some(port), + timeout: None, }) } + pub fn inject_config(&self, worktree: &Worktree, config_string: String) -> zed::Result { + let config: Value = serde_json::from_str(&config_string) + .map_err(|err| format!("Failed to parse debug config {err}"))?; + + if config + .get("request") + .map(Value::as_str) + .flatten() + .is_some_and(|req| req != "launch") + { + return Ok(config_string); + } + + let mut config = serde_json::from_value::(config) + .map_err(|err| format!("Failed to parse java debug config {err}"))?; + + let workspace_folder = worktree.root_path(); + + let (main_class, project_name) = { + let arguments = [config.main_class.clone(), config.project_name.clone()] + .iter() + .flatten() + .cloned() + .collect::>(); + + let entries = self + .lsp + .resolve_main_class(arguments)? + .into_iter() + .filter(|entry| { + config + .main_class + .as_ref() + .map(|class| &entry.main_class == class) + .unwrap_or(true) + }) + .filter(|entry| { + config + .project_name + .as_ref() + .map(|class| &entry.project_name == class) + .unwrap_or(true) + }) + .collect::>(); + + if entries.len() > 1 { + return Err("Project have multiple entry points, you must explicitly specify \"mainClass\" or \"projectName\"".to_owned()); + } + + match entries.get(0) { + None => (config.main_class, config.project_name), + Some(entry) => ( + Some(entry.main_class.to_owned()), + Some(entry.project_name.to_owned()), + ), + } + }; + + let mut classpaths = config.class_paths.unwrap_or(vec![AUTO_SCOPE.to_string()]); + + if classpaths + .iter() + .any(|class| SCOPES.contains(&class.as_str())) + { + // https://github.com/microsoft/vscode-java-debug/blob/main/src/configurationProvider.ts#L518 + let scope = { + if classpaths.iter().any(|class| class == TEST_SCOPE) { + Some("test".to_string()) + } else if classpaths.iter().any(|class| class == AUTO_SCOPE) { + None + } else if classpaths.iter().any(|class| class == RUNTIME_SCOPE) { + Some("runtime".to_string()) + } else { + None + } + }; + + let arguments = vec![main_class.clone(), project_name.clone(), scope.clone()]; + + let result = self.lsp.resolve_class_path(arguments)?; + + for resolved in result { + classpaths.extend(resolved); + } + } + + classpaths.retain(|class| !SCOPES.contains(&class.as_str())); + classpaths.dedup(); + + config.class_paths = Some(classpaths); + + config.main_class = main_class; + config.project_name = project_name; + + config.cwd = config.cwd.or(Some(workspace_folder.to_string())); + + let config = serde_json::to_string(&config) + .map_err(|err| format!("Failed to stringify debug config {err}"))? + .replace("${workspaceFolder}", &workspace_folder); + + Ok(config) + } + pub fn inject_plugin_into_options( &self, initialization_options: Option, @@ -166,7 +310,11 @@ impl Debugger { let canonical_path = Value::String( current_dir - .join(self.path.as_ref().ok_or("Debugger is not loaded yet")?) + .join( + self.plugin_path + .as_ref() + .ok_or("Debugger is not loaded yet")?, + ) .to_string_lossy() .to_string(), ); @@ -180,7 +328,6 @@ impl Debugger { Some(options) => { let mut options = options.clone(); - // ensure bundles field exists let mut bundles = options .get_mut("bundles") .unwrap_or(&mut Value::Array(vec![])) diff --git a/src/lib.rs b/src/lib.rs index 3074d74..bda8a1e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,22 +2,22 @@ mod debugger; mod lsp; use std::{ collections::BTreeSet, - env::{self, current_dir}, - fs::{self, create_dir, read_dir}, - net::Ipv4Addr, + env::current_dir, + fs::{self, create_dir}, path::{Path, PathBuf}, str::FromStr, + sync::Arc, }; use zed_extension_api::{ self as zed, CodeLabel, CodeLabelSpan, DebugAdapterBinary, DebugTaskDefinition, DownloadedFileType, Extension, LanguageServerId, LanguageServerInstallationStatus, Os, - StartDebuggingRequestArguments, StartDebuggingRequestArgumentsRequest, TcpArguments, Worktree, + StartDebuggingRequestArguments, StartDebuggingRequestArgumentsRequest, Worktree, current_platform, download_file, http_client::{HttpMethod, HttpRequest, fetch}, lsp::{Completion, CompletionKind}, make_file_executable, register_extension, - serde_json::{self, Map, Value, json}, + serde_json::{self, Value, json}, set_language_server_installation_status, settings::LspSettings, }; @@ -31,15 +31,39 @@ const PATH_TO_STR_ERROR: &str = "failed to convert path to string"; struct Java { cached_binary_path: Option, cached_lombok_path: Option, - debugger: Debugger, + integrations: Option<(Arc, Debugger)>, } impl Java { + #[allow(dead_code)] + fn lsp(&mut self) -> zed::Result<&Arc> { + self.integrations + .as_ref() + .ok_or("Lsp client is not initialized yet".to_owned()) + .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()) + .map(|v| &mut v.1) + } + fn language_server_binary_path( &mut self, language_server_id: &LanguageServerId, worktree: &Worktree, ) -> zed::Result { + // Initialize lsp client and debugger + + if self.integrations.is_none() { + let lsp = Arc::new(LspClient::new(worktree.root_path())); + let debugger = Debugger::new(lsp.clone()); + + self.integrations = Some((lsp, debugger)); + } + // Use cached path if exists if let Some(path) = &self.cached_binary_path { @@ -293,7 +317,7 @@ impl Extension for Java { Self { cached_binary_path: None, cached_lombok_path: None, - debugger: Debugger::new(), + integrations: None, } } @@ -321,9 +345,11 @@ impl Extension for Java { Value::from_str(config.config.as_str()) .map_err(|e| format!("Invalid JSON configuration: {e}"))?, )?, - configuration: config.config, + configuration: self.debugger()?.inject_config(worktree, config.config)?, }, - connection: Some(self.debugger.start_session(worktree)?), + connection: Some(zed::resolve_tcp_template( + self.debugger()?.start_session()?, + )?), }) } @@ -350,6 +376,31 @@ impl Extension for Java { } } + fn dap_config_to_scenario( + &mut self, + config: zed::DebugConfig, + ) -> zed::Result { + return match config.request { + zed::DebugRequest::Attach(attach) => { + return Ok(zed::DebugScenario { + adapter: config.adapter, + build: None, + tcp_connection: Some(self.debugger()?.start_session()?), + label: "Attach to Java process".to_string(), + config: json!({ + "request": "attach", + "processId": attach.process_id, + "stopOnEntry": config.stop_on_entry + }) + .to_string(), + }); + } + zed::DebugRequest::Launch(_launch) => { + Err("Java DAP doesn't support launching".to_string()) + } + }; + } + fn language_server_command( &mut self, language_server_id: &LanguageServerId, @@ -365,9 +416,6 @@ impl Extension for Java { .to_path_buf(); } - // download debugger if not exists - self.debugger.get_or_download(language_server_id)?; - let configuration = self.language_server_workspace_configuration(language_server_id, worktree)?; let java_home = configuration.as_ref().and_then(|configuration| { @@ -420,6 +468,9 @@ impl Extension for Java { args.push(format!("--jvm-arg=-javaagent:{canonical_lombok_jar_path}")); } + // download debugger if not exists + self.debugger()?.get_or_download(language_server_id)?; + Ok(zed::Command { command: zed::node_binary_path()?, args, @@ -435,7 +486,11 @@ impl Extension for Java { let options = LspSettings::for_worktree(language_server_id.as_ref(), worktree) .map(|lsp_settings| lsp_settings.initialization_options)?; - Ok(Some(self.debugger.inject_plugin_into_options(options)?)) + if self.integrations.is_some() { + return Ok(Some(self.debugger()?.inject_plugin_into_options(options)?)); + } + + return Ok(options); } fn language_server_workspace_configuration( diff --git a/src/lsp.rs b/src/lsp.rs index a0bd747..9213887 100644 --- a/src/lsp.rs +++ b/src/lsp.rs @@ -3,8 +3,10 @@ use std::{ path::Path, }; +use serde::{Deserialize, Serialize, de::DeserializeOwned}; +use serde_json::json; use zed_extension_api::{ - Worktree, + self as zed, http_client::{HttpMethod, HttpRequest, fetch}, serde_json::{self, Map, Value}, }; @@ -17,18 +19,47 @@ use zed_extension_api::{ * It’s a temporary workaround until `zed_extension_api` * provides the ability to send LSP requests directly. */ -pub struct LspClient {} +pub struct LspClient { + workspace: String, +} impl LspClient { - pub fn request(worktree: &Worktree, method: &str, params: Value) -> Result { + pub fn new(workspace: String) -> LspClient { + LspClient { workspace } + } + + pub fn resolve_class_path(&self, args: Vec>) -> zed::Result>> { + self.request::>>( + "workspace/executeCommand", + json!({ + "command": "vscode.java.resolveClasspath", + "arguments": args + }), + ) + } + + pub fn resolve_main_class(&self, args: Vec) -> zed::Result> { + self.request::>( + "workspace/executeCommand", + json!({ + "command": "vscode.java.resolveMainClass", + "arguments": args + }), + ) + } + + pub fn request(&self, method: &str, params: Value) -> Result + where + T: DeserializeOwned, + { // We cannot cache it because the user may restart the LSP let port = { - let filename = string_to_hex(worktree.root_path().as_str()); + let filename = string_to_hex(&self.workspace); let port_path = Path::new("proxy").join(filename); if !fs::metadata(&port_path).is_ok_and(|file| file.is_file()) { - return Err("Lsp proxy is not running".to_owned()); + return Err("Failed to find lsp port file".to_owned()); } fs::read_to_string(port_path) @@ -50,8 +81,15 @@ impl LspClient { ) .map_err(|e| format!("Failed to send request to lsp proxy {e}"))?; - Ok(serde_json::from_slice(&res.body) - .map_err(|e| format!("Failed to parse response from lsp proxy {e}"))?) + let data: LspResponse = serde_json::from_slice(&res.body) + .map_err(|e| format!("Failed to parse response from lsp proxy {e}"))?; + + match data { + LspResponse::Sucess { result } => return Ok(result), + LspResponse::Error { error } => { + return Err(format!("{} {} {}", error.code, error.message, error.data)); + } + } } } fn string_to_hex(s: &str) -> String { @@ -61,3 +99,25 @@ fn string_to_hex(s: &str) -> String { } hex_string } + +#[derive(Serialize, Deserialize)] +#[serde(untagged)] +pub enum LspResponse { + Sucess { result: T }, + Error { error: LspError }, +} + +#[derive(Serialize, Deserialize)] +pub struct LspError { + code: i64, + message: String, + data: Value, +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MainClassEntry { + pub main_class: String, + pub project_name: String, + pub file_path: String, +} From 8c9420c18c4661b191e92b1a6ed4a9a49d791995 Mon Sep 17 00:00:00 2001 From: DeityLamb Date: Sun, 17 Aug 2025 13:30:42 +0300 Subject: [PATCH 7/9] fix: handle workspace switch properly --- debug_adapter_schemas/Java.json | 64 ++++++++++++++++++++++++--------- src/debugger.rs | 14 ++++---- src/lib.rs | 42 +++++++++++++++------- src/lsp.rs | 29 +++++++++++++-- src/proxy.mjs | 10 +----- 5 files changed, 111 insertions(+), 48 deletions(-) diff --git a/debug_adapter_schemas/Java.json b/debug_adapter_schemas/Java.json index 73a9f1a..896a3b9 100644 --- a/debug_adapter_schemas/Java.json +++ b/debug_adapter_schemas/Java.json @@ -24,7 +24,7 @@ }, "vmArgs": { "type": "string", - "description": "The extra options and system properties for the JVM (e.g., -Xms -Xmx -D=). It accepts a string or an array of strings." + "description": "The extra options and system properties for the JVM (e.g., -Xms -Xmx -D=)." }, "encoding": { "type": "string", @@ -42,7 +42,7 @@ "items": { "type": "string" }, - "description": "The modulepaths for launching the JVM. If not specified, the debugger will automatically resolve them from the current project. If multiple values are specified, the debugger will merge them together." + "description": "The modulepaths for launching the JVM. If not specified, the debugger will automatically resolve them from the current project." }, "cwd": { "type": "string", @@ -66,11 +66,11 @@ "console": { "type": "string", "enum": ["internalConsole", "integratedTerminal", "externalTerminal"], - "description": "The specified console to launch the program. If not specified, use the console specified by the 'java.debug.settings.console' user setting." + "description": "The specified console to launch the program." }, "shortenCommandLine": { "type": "string", - "enum": ["none", "jarmanifest", "argfile", "auto"], + "enum": ["none", "jarmanifest", "argfile"], "description": "Provides multiple approaches to shorten the command line when it exceeds the maximum command line string limitation allowed by the OS." }, "launcherScript": { @@ -100,24 +100,54 @@ "type": "integer", "description": "The debug port of the remote debuggee." }, - "processId": { - "type": "string", - "description": "Use a process picker to select a process to attach, or provide a Process ID as an integer. Use '${command:PickJavaProcess}' to prompt the user to select a Java process." - }, "timeout": { "type": "integer", "description": "Timeout value before reconnecting, in milliseconds (default to 30000ms)." - } - }, - "required": ["request"], - "anyOf": [ - { - "required": ["hostName", "port"] }, - { - "required": ["processId"] + "projectName": { + "type": "string", + "description": "The fully qualified name of the project" + }, + "sourcePaths": { + "type": "array", + "items": { + "type": "string" + }, + "description": "The source paths for the debugger. If not specified, the debugger will automatically resolve the source paths from the current project." + }, + "stepFilters": { + "type": "object", + "properties": { + "allowClasses": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Restricts the events generated by the request to those whose location is in a class whose name matches this restricted regular expression. Regular expressions are limited to exact matches and patterns that begin with '*' or end with '*'; for example, \"*.Foo\" or \"java.*\"." + }, + "skipClasses": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Restricts the events generated by the request to those whose location is in a class whose name does not match this restricted regular expression, e.g. \"java.*\" or \"*.Foo\"." + }, + "skipSynthetics": { + "type": "boolean", + "description": "If true, skips synthetic methods." + }, + "skipStaticInitializers": { + "type": "boolean", + "description": "If true, skips static initializers." + }, + "skipConstructors": { + "type": "boolean", + "description": "If true, skips constructors." + } + } } - ] + }, + "required": ["request", "hostName", "port"] } ] } diff --git a/src/debugger.rs b/src/debugger.rs index c8e4b2a..1a8074d 100644 --- a/src/debugger.rs +++ b/src/debugger.rs @@ -1,7 +1,6 @@ -use std::{collections::HashMap, env::current_dir, fs, path::PathBuf, sync::Arc}; +use std::{collections::HashMap, env::current_dir, fs, path::PathBuf}; use serde::{Deserialize, Serialize}; -use serde_json::Map; use zed_extension_api::{ self as zed, DownloadedFileType, LanguageServerId, LanguageServerInstallationStatus, Os, TcpArgumentsTemplate, Worktree, current_platform, download_file, @@ -10,7 +9,7 @@ use zed_extension_api::{ set_language_server_installation_status, }; -use crate::lsp::LspClient; +use crate::lsp::LspWrapper; #[derive(Serialize, Deserialize, Debug)] #[serde(rename_all = "camelCase")] @@ -60,12 +59,12 @@ const MAVEN_SEARCH_URL: &str = "https://search.maven.org/solrsearch/select?q=a:com.microsoft.java.debug.plugin"; pub struct Debugger { - lsp: Arc, + lsp: LspWrapper, plugin_path: Option, } impl Debugger { - pub fn new(lsp: Arc) -> Debugger { + pub fn new(lsp: LspWrapper) -> Debugger { Debugger { plugin_path: None, lsp, @@ -179,7 +178,7 @@ impl Debugger { } pub fn start_session(&self) -> zed::Result { - let port = self.lsp.request::( + let port = self.lsp.get()?.request::( "workspace/executeCommand", json!({ "command": "vscode.java.startDebugSession" }), )?; @@ -218,6 +217,7 @@ impl Debugger { let entries = self .lsp + .get()? .resolve_main_class(arguments)? .into_iter() .filter(|entry| { @@ -270,7 +270,7 @@ impl Debugger { let arguments = vec![main_class.clone(), project_name.clone(), scope.clone()]; - let result = self.lsp.resolve_class_path(arguments)?; + let result = self.lsp.get()?.resolve_class_path(arguments)?; for resolved in result { classpaths.extend(resolved); diff --git a/src/lib.rs b/src/lib.rs index bda8a1e..5a1efb4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,7 +6,6 @@ use std::{ fs::{self, create_dir}, path::{Path, PathBuf}, str::FromStr, - sync::Arc, }; use zed_extension_api::{ @@ -22,7 +21,7 @@ use zed_extension_api::{ settings::LspSettings, }; -use crate::{debugger::Debugger, lsp::LspClient}; +use crate::{debugger::Debugger, lsp::LspWrapper}; const PROXY_FILE: &str = include_str!("proxy.mjs"); const DEBUG_ADAPTER_NAME: &str = "Java"; @@ -31,12 +30,12 @@ const PATH_TO_STR_ERROR: &str = "failed to convert path to string"; struct Java { cached_binary_path: Option, cached_lombok_path: Option, - integrations: Option<(Arc, Debugger)>, + integrations: Option<(LspWrapper, Debugger)>, } impl Java { #[allow(dead_code)] - fn lsp(&mut self) -> zed::Result<&Arc> { + fn lsp(&mut self) -> zed::Result<&LspWrapper> { self.integrations .as_ref() .ok_or("Lsp client is not initialized yet".to_owned()) @@ -58,7 +57,7 @@ impl Java { // Initialize lsp client and debugger if self.integrations.is_none() { - let lsp = Arc::new(LspClient::new(worktree.root_path())); + let lsp = LspWrapper::new(worktree.root_path()); let debugger = Debugger::new(lsp.clone()); self.integrations = Some((lsp, debugger)); @@ -334,6 +333,10 @@ impl Extension for Java { )); } + if self.integrations.is_some() { + self.lsp()?.switch_workspace(worktree.root_path())?; + } + Ok(DebugAdapterBinary { command: None, arguments: vec![], @@ -382,21 +385,31 @@ impl Extension for Java { ) -> zed::Result { return match config.request { zed::DebugRequest::Attach(attach) => { + let debug_config = if let Some(process_id) = attach.process_id { + json!({ + "request": "attach", + "processId": process_id, + "stopOnEntry": config.stop_on_entry + }) + } else { + json!({ + "request": "attach", + "hostName": "localhost", + "port": 5005, + }) + }; + return Ok(zed::DebugScenario { adapter: config.adapter, build: None, tcp_connection: Some(self.debugger()?.start_session()?), label: "Attach to Java process".to_string(), - config: json!({ - "request": "attach", - "processId": attach.process_id, - "stopOnEntry": config.stop_on_entry - }) - .to_string(), + config: debug_config.to_string(), }); } + zed::DebugRequest::Launch(_launch) => { - Err("Java DAP doesn't support launching".to_string()) + Err("Java Extension doesn't support launching".to_string()) } }; } @@ -470,6 +483,7 @@ impl Extension for Java { // download debugger if not exists self.debugger()?.get_or_download(language_server_id)?; + self.lsp()?.switch_workspace(worktree.root_path())?; Ok(zed::Command { command: zed::node_binary_path()?, @@ -483,6 +497,10 @@ impl Extension for Java { language_server_id: &LanguageServerId, worktree: &Worktree, ) -> zed::Result> { + if self.integrations.is_some() { + self.lsp()?.switch_workspace(worktree.root_path())?; + } + let options = LspSettings::for_worktree(language_server_id.as_ref(), worktree) .map(|lsp_settings| lsp_settings.initialization_options)?; diff --git a/src/lsp.rs b/src/lsp.rs index 9213887..67d3702 100644 --- a/src/lsp.rs +++ b/src/lsp.rs @@ -1,6 +1,7 @@ use std::{ fs::{self}, path::Path, + sync::{Arc, RwLock}, }; use serde::{Deserialize, Serialize, de::DeserializeOwned}; @@ -23,11 +24,33 @@ pub struct LspClient { workspace: String, } -impl LspClient { - pub fn new(workspace: String) -> LspClient { - LspClient { workspace } +#[derive(Clone)] +pub struct LspWrapper(Arc>); + +impl LspWrapper { + pub fn new(workspace: String) -> Self { + LspWrapper(Arc::new(RwLock::new(LspClient { workspace }))) + } + + pub fn get(&self) -> zed::Result> { + self.0 + .read() + .map_err(|err| format!("LspClient RwLock poisoned during read {err}")) + } + + pub fn switch_workspace(&self, workspace: String) -> zed::Result<()> { + let mut lock = self + .0 + .write() + .map_err(|err| format!("LspClient RwLock poisoned during read {err}"))?; + + lock.workspace = workspace; + + Ok(()) } +} +impl LspClient { pub fn resolve_class_path(&self, args: Vec>) -> zed::Result>> { self.request::>>( "workspace/executeCommand", diff --git a/src/proxy.mjs b/src/proxy.mjs index b0ca331..686eab1 100644 --- a/src/proxy.mjs +++ b/src/proxy.mjs @@ -61,15 +61,7 @@ const server = createServer(async (req, res) => { res.write(JSON.stringify(result)); res.end(); }).listen(HTTP_PORT, () => { - const dir = dirname(PROXY_HTTP_PORT_FILE); - - if (existsSync(dir)) { - for (const file of readdirSync(dir)) { - unlinkSync(join(dir, file)); - } - } - - mkdirSync(dir, { recursive: true }); + mkdirSync(dirname(PROXY_HTTP_PORT_FILE), { recursive: true }); writeFileSync(PROXY_HTTP_PORT_FILE, server.address().port.toString()); }); From 0aa5d0f9368a985f5e62049eadf24d8fdfd2e50f Mon Sep 17 00:00:00 2001 From: Valentine Briese Date: Tue, 19 Aug 2025 12:23:08 -0700 Subject: [PATCH 8/9] Resolve Clippy lints --- src/debugger.rs | 34 +++++++++++++----------------- src/lib.rs | 55 +++++++++++++++++++++++-------------------------- src/lsp.rs | 6 +++--- 3 files changed, 43 insertions(+), 52 deletions(-) diff --git a/src/debugger.rs b/src/debugger.rs index 1a8074d..8f778ed 100644 --- a/src/debugger.rs +++ b/src/debugger.rs @@ -77,10 +77,10 @@ impl Debugger { ) -> zed::Result { let prefix = "debugger"; - if let Some(path) = &self.plugin_path { - if fs::metadata(path).is_ok_and(|stat| stat.is_file()) { - return Ok(path.clone()); - } + if let Some(path) = &self.plugin_path + && fs::metadata(path).is_ok_and(|stat| stat.is_file()) + { + return Ok(path.clone()); } set_language_server_installation_status( @@ -106,10 +106,9 @@ impl Debugger { return Err(err.to_owned()); } - let exists = fs::read_dir(&prefix) + let exists = fs::read_dir(prefix) .ok() - .map(|dir| dir.last().map(|v| v.ok())) - .flatten() + .and_then(|dir| dir.last().map(|v| v.ok())) .flatten(); if let Some(file) = exists { @@ -137,14 +136,12 @@ impl Debugger { let latest_version = maven_response_body .pointer("/response/docs/0/latestVersion") - .map(|v| v.as_str()) - .flatten() + .and_then(|v| v.as_str()) .ok_or("Malformed maven response")?; let artifact = maven_response_body .pointer("/response/docs/0/a") - .map(|v| v.as_str()) - .flatten() + .and_then(|v| v.as_str()) .ok_or("Malformed maven response")?; let jar_name = format!("{artifact}-{latest_version}.jar"); @@ -196,8 +193,7 @@ impl Debugger { if config .get("request") - .map(Value::as_str) - .flatten() + .and_then(Value::as_str) .is_some_and(|req| req != "launch") { return Ok(config_string); @@ -240,7 +236,7 @@ impl Debugger { return Err("Project have multiple entry points, you must explicitly specify \"mainClass\" or \"projectName\"".to_owned()); } - match entries.get(0) { + match entries.first() { None => (config.main_class, config.project_name), Some(entry) => ( Some(entry.main_class.to_owned()), @@ -320,11 +316,9 @@ impl Debugger { ); match initialization_options { - None => { - return Ok(json!({ - "bundles": [canonical_path] - })); - } + None => Ok(json!({ + "bundles": [canonical_path] + })), Some(options) => { let mut options = options.clone(); @@ -343,7 +337,7 @@ impl Debugger { options["bundles"] = bundles; - return Ok(options); + Ok(options) } } } diff --git a/src/lib.rs b/src/lib.rs index 5a1efb4..19cec4b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -65,10 +65,10 @@ impl Java { // Use cached path if exists - if let Some(path) = &self.cached_binary_path { - if fs::metadata(path).is_ok_and(|stat| stat.is_file()) { - return Ok(path.clone()); - } + if let Some(path) = &self.cached_binary_path + && fs::metadata(path).is_ok_and(|stat| stat.is_file()) + { + return Ok(path.clone()); } // Use $PATH if binary is in it @@ -198,10 +198,10 @@ impl Java { for entry in entries { match entry { Ok(entry) => { - if entry.file_name().to_str() != Some(build_directory) { - if let Err(err) = fs::remove_dir_all(entry.path()) { - println!("failed to remove directory entry: {err}"); - } + 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}"), @@ -222,10 +222,10 @@ impl Java { fn lombok_jar_path(&mut self, language_server_id: &LanguageServerId) -> zed::Result { // Use cached path if exists - if let Some(path) = &self.cached_lombok_path { - if fs::metadata(path).is_ok_and(|stat| stat.is_file()) { - return Ok(path.clone()); - } + if let Some(path) = &self.cached_lombok_path + && fs::metadata(path).is_ok_and(|stat| stat.is_file()) + { + return Ok(path.clone()); } // Check for latest version @@ -286,10 +286,10 @@ impl Java { for entry in entries { match entry { Ok(entry) => { - if entry.file_name().to_str() != Some(&jar_name) { - if let Err(err) = fs::remove_dir_all(entry.path()) { - println!("failed to remove directory entry: {err}"); - } + 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}"), @@ -383,7 +383,7 @@ impl Extension for Java { &mut self, config: zed::DebugConfig, ) -> zed::Result { - return match config.request { + match config.request { zed::DebugRequest::Attach(attach) => { let debug_config = if let Some(process_id) = attach.process_id { json!({ @@ -399,19 +399,19 @@ impl Extension for Java { }) }; - return Ok(zed::DebugScenario { + Ok(zed::DebugScenario { adapter: config.adapter, build: None, tcp_connection: Some(self.debugger()?.start_session()?), label: "Attach to Java process".to_string(), config: debug_config.to_string(), - }); + }) } zed::DebugRequest::Launch(_launch) => { Err("Java Extension doesn't support launching".to_string()) } - }; + } } fn language_server_command( @@ -447,19 +447,16 @@ impl Extension for Java { env.push(("JAVA_HOME".to_string(), java_home)); } - let mut args = Vec::new(); - - args.push("-e".to_string()); - args.push(PROXY_FILE.to_string()); - args.push(current_dir.to_str().ok_or(PATH_TO_STR_ERROR)?.to_string()); - - args.push( + let mut args = vec![ + "-e".to_string(), + PROXY_FILE.to_string(), + current_dir.to_str().ok_or(PATH_TO_STR_ERROR)?.to_string(), current_dir .join(self.language_server_binary_path(language_server_id, worktree)?) .to_str() .ok_or(PATH_TO_STR_ERROR)? .to_string(), - ); + ]; // Add lombok as javaagent if settings.java.jdt.ls.lombokSupport.enabled is true let lombok_enabled = configuration @@ -508,7 +505,7 @@ impl Extension for Java { return Ok(Some(self.debugger()?.inject_plugin_into_options(options)?)); } - return Ok(options); + Ok(options) } fn language_server_workspace_configuration( diff --git a/src/lsp.rs b/src/lsp.rs index 67d3702..c567483 100644 --- a/src/lsp.rs +++ b/src/lsp.rs @@ -108,9 +108,9 @@ impl LspClient { .map_err(|e| format!("Failed to parse response from lsp proxy {e}"))?; match data { - LspResponse::Sucess { result } => return Ok(result), + LspResponse::Success { result } => Ok(result), LspResponse::Error { error } => { - return Err(format!("{} {} {}", error.code, error.message, error.data)); + Err(format!("{} {} {}", error.code, error.message, error.data)) } } } @@ -126,7 +126,7 @@ fn string_to_hex(s: &str) -> String { #[derive(Serialize, Deserialize)] #[serde(untagged)] pub enum LspResponse { - Sucess { result: T }, + Success { result: T }, Error { error: LspError }, } From fa90d37057e9ba9a9f10ff297ebce44e7a37812f Mon Sep 17 00:00:00 2001 From: DeityLamb Date: Wed, 20 Aug 2025 09:35:27 +0300 Subject: [PATCH 9/9] fix: explicitly specify --input-type=module for node <22.7.0 --- src/lib.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/lib.rs b/src/lib.rs index 19cec4b..0e51ce3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -448,6 +448,7 @@ impl Extension for Java { } let mut args = vec![ + "--input-type=module".to_string(), "-e".to_string(), PROXY_FILE.to_string(), current_dir.to_str().ok_or(PATH_TO_STR_ERROR)?.to_string(),