diff --git a/Cargo.toml b/Cargo.toml index 315687c..f14fc05 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,4 +7,6 @@ edition = "2024" crate-type = ["cdylib"] [dependencies] -zed_extension_api = "0.3.0" +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 new file mode 100644 index 0000000..896a3b9 --- /dev/null +++ b/debug_adapter_schemas/Java.json @@ -0,0 +1,153 @@ +{ + "$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\"." + }, + "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 the current project." + }, + "args": { + "type": "string", + "description": "The command line arguments passed to the program." + }, + "vmArgs": { + "type": "string", + "description": "The extra options and system properties for the JVM (e.g., -Xms -Xmx -D=)." + }, + "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 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." + }, + "modulePaths": { + "type": "array", + "items": { + "type": "string" + }, + "description": "The modulepaths for launching the JVM. If not specified, the debugger will automatically resolve them from the current project." + }, + "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" + } + }, + "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." + }, + "shortenCommandLine": { + "type": "string", + "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": { + "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 the Java executable to use. By default, the project JDK's Java executable is used." + } + }, + "required": ["request"] + }, + { + "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 the remote debuggee." + }, + "port": { + "type": "integer", + "description": "The debug port of the remote debuggee." + }, + "timeout": { + "type": "integer", + "description": "Timeout value before reconnecting, in milliseconds (default to 30000ms)." + }, + "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/extension.toml b/extension.toml index 827e59a..61977d6 100644 --- a/extension.toml +++ b/extension.toml @@ -21,3 +21,5 @@ commit = "579b62f5ad8d96c2bb331f07d1408c92767531d9" [language_servers.jdtls] name = "Eclipse JDT Language Server" language = "Java" + +[debug_adapters.Java] 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 new file mode 100644 index 0000000..8f778ed --- /dev/null +++ b/src/debugger.rs @@ -0,0 +1,344 @@ +use std::{collections::HashMap, env::current_dir, fs, path::PathBuf}; + +use serde::{Deserialize, Serialize}; +use zed_extension_api::{ + self as zed, DownloadedFileType, LanguageServerId, LanguageServerInstallationStatus, Os, + TcpArgumentsTemplate, Worktree, current_platform, download_file, + http_client::{HttpMethod, HttpRequest, fetch}, + serde_json::{self, Value, json}, + set_language_server_installation_status, +}; + +use crate::lsp::LspWrapper; + +#[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 { + lsp: LspWrapper, + plugin_path: Option, +} + +impl Debugger { + pub fn new(lsp: LspWrapper) -> Debugger { + Debugger { + plugin_path: None, + lsp, + } + } + + pub fn get_or_download( + &mut self, + language_server_id: &LanguageServerId, + ) -> zed::Result { + let prefix = "debugger"; + + 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( + 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() + .and_then(|dir| dir.last().map(|v| v.ok())) + .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.plugin_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") + .and_then(|v| v.as_str()) + .ok_or("Malformed maven response")?; + + let artifact = maven_response_body + .pointer("/response/docs/0/a") + .and_then(|v| v.as_str()) + .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(PATH_TO_STR_ERROR)?, + DownloadedFileType::Uncompressed, + ) + .map_err(|err| format!("Failed to download {url} {err}"))?; + } + + self.plugin_path = Some(jar_path.clone()); + Ok(jar_path) + } + + pub fn start_session(&self) -> zed::Result { + let port = self.lsp.get()?.request::( + "workspace/executeCommand", + 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") + .and_then(Value::as_str) + .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 + .get()? + .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.first() { + 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.get()?.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, + ) -> 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.plugin_path + .as_ref() + .ok_or("Debugger is not loaded yet")?, + ) + .to_string_lossy() + .to_string(), + ); + + match initialization_options { + None => Ok(json!({ + "bundles": [canonical_path] + })), + Some(options) => { + let mut options = options.clone(); + + 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; + + Ok(options) + } + } + } +} diff --git a/src/lib.rs b/src/lib.rs index f828e96..0e51ce3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,40 +1,74 @@ +mod debugger; +mod lsp; use std::{ collections::BTreeSet, env::current_dir, fs::{self, create_dir}, path::{Path, PathBuf}, + str::FromStr, }; 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, 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, Value, json}, set_language_server_installation_status, settings::LspSettings, }; +use crate::{debugger::Debugger, lsp::LspWrapper}; + +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, + integrations: Option<(LspWrapper, Debugger)>, } impl Java { + #[allow(dead_code)] + fn lsp(&mut self) -> zed::Result<&LspWrapper> { + 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 = LspWrapper::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 { - 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 @@ -164,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}"), @@ -188,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 @@ -252,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}"), @@ -282,6 +316,101 @@ impl Extension for Java { Self { cached_binary_path: None, cached_lombok_path: None, + integrations: 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}\"" + )); + } + + if self.integrations.is_some() { + self.lsp()?.switch_workspace(worktree.root_path())?; + } + + Ok(DebugAdapterBinary { + command: None, + arguments: vec![], + cwd: Some(worktree.root_path()), + envs: vec![], + request_args: StartDebuggingRequestArguments { + request: self.dap_request_kind( + adapter_name, + Value::from_str(config.config.as_str()) + .map_err(|e| format!("Invalid JSON configuration: {e}"))?, + )?, + configuration: self.debugger()?.inject_config(worktree, config.config)?, + }, + connection: Some(zed::resolve_tcp_template( + self.debugger()?.start_session()?, + )?), + }) + } + + 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(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:?}" + )), + None => { + Err("Missing required `request` field in Java debug adapter configuration".into()) + } + } + } + + fn dap_config_to_scenario( + &mut self, + config: zed::DebugConfig, + ) -> zed::Result { + 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, + }) + }; + + 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()) + } } } @@ -290,6 +419,16 @@ 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 configuration = self.language_server_workspace_configuration(language_server_id, worktree)?; let java_home = configuration.as_ref().and_then(|configuration| { @@ -301,13 +440,25 @@ 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 { env.push(("JAVA_HOME".to_string(), java_home)); } - let mut args = Vec::new(); + 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(), + 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 +469,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) @@ -338,12 +479,12 @@ 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)?; + self.lsp()?.switch_workspace(worktree.root_path())?; + 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 +495,18 @@ 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) + 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)?; + + if self.integrations.is_some() { + return Ok(Some(self.debugger()?.inject_plugin_into_options(options)?)); + } + + Ok(options) } fn language_server_workspace_configuration( diff --git a/src/lsp.rs b/src/lsp.rs new file mode 100644 index 0000000..c567483 --- /dev/null +++ b/src/lsp.rs @@ -0,0 +1,146 @@ +use std::{ + fs::{self}, + path::Path, + sync::{Arc, RwLock}, +}; + +use serde::{Deserialize, Serialize, de::DeserializeOwned}; +use serde_json::json; +use zed_extension_api::{ + self as zed, + 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 { + workspace: String, +} + +#[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", + 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(&self.workspace); + + let port_path = Path::new("proxy").join(filename); + + if !fs::metadata(&port_path).is_ok_and(|file| file.is_file()) { + return Err("Failed to find lsp port file".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}"))?; + + let data: LspResponse = serde_json::from_slice(&res.body) + .map_err(|e| format!("Failed to parse response from lsp proxy {e}"))?; + + match data { + LspResponse::Success { result } => Ok(result), + LspResponse::Error { error } => { + Err(format!("{} {} {}", error.code, error.message, error.data)) + } + } + } +} +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 +} + +#[derive(Serialize, Deserialize)] +#[serde(untagged)] +pub enum LspResponse { + Success { 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, +} diff --git a/src/proxy.mjs b/src/proxy.mjs new file mode 100644 index 0000000..686eab1 --- /dev/null +++ b/src/proxy.mjs @@ -0,0 +1,260 @@ +import { Buffer } from "node:buffer"; +import { spawn } from "node:child_process"; +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 { 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 LENGTH_HEADER = "Content-Length"; +const TIMEOUT = 5_000; + +const workdir = process.argv[1]; +const bin = process.argv[2]; +const args = process.argv.slice(3); + +const PROXY_ID = Buffer.from(process.cwd().replace(/\/+$/, "")).toString("hex"); +const PROXY_HTTP_PORT_FILE = join(workdir, "proxy", PROXY_ID); + +const lsp = spawn(bin, args); +const proxy = createLspProxy({ server: lsp, proxy: process }); + +proxy.on("client", (data, passthrough) => { + passthrough(); +}); +proxy.on("server", (data, passthrough) => { + passthrough(); +}); + +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, () => { + mkdirSync(dirname(PROXY_HTTP_PORT_FILE), { recursive: true }); + writeFileSync(PROXY_HTTP_PORT_FILE, server.address().port.toString()); +}); + +export function createLspProxy({ + 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(lspMessageSeparator()).on("data", (data) => { + events.emit("client", parse(data), () => serverStdin.write(data)); + }); + + serverStdout.pipe(lspMessageSeparator()).on("data", (data) => { + const message = parse(data); + + 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 {string} method + * @param {any} params + * @returns void + */ + notification(method, params) { + proxyStdout.write(stringify({ jsonrpc: "2.0", method, params })); + }, + + /** + * + * @param {string} method + * @param {any} params + * @returns Promise + */ + 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 () => 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; + let headersLength = null; + + return new Transform({ + transform(chunk, encoding, callback) { + buffer = Buffer.concat([buffer, chunk]); + + // A single chunk may contain multiple messages + while (true) { + // Wait until we get the whole headers block + if (buffer.indexOf(CONTENT_SEPARATOR) === -1) { + break; + } + + 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[LENGTH_HEADER.toLowerCase()], 10); + headersLength = headersEnd + CONTENT_SEPARATOR.length; + } + + const msgLength = headersLength + contentLength; + + // Wait until we get the whole content part + if (buffer.length < msgLength) { + break; + } + + this.push(buffer.subarray(0, msgLength)); + + buffer = buffer.subarray(msgLength); + contentLength = null; + headersLength = null; + } + + callback(); + }, + }); +} + +/** + * + * @param {any} content + * @returns {string} + */ +function stringify(content) { + const json = JSON.stringify(content); + return ( + LENGTH_HEADER + + NAME_VALUE_SEPARATOR + + json.length + + CONTENT_SEPARATOR + + json + ); +} + +/** + * + * @param {string} message + * @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 { + return JSON.parse(json); + } catch (err) { + return null; + } +}