From 849e57163aa4c3e9e1ec9f949a41aba7c0a9d615 Mon Sep 17 00:00:00 2001 From: max <36980911+pr2502@users.noreply.github.com> Date: Fri, 20 Oct 2023 00:24:55 +0200 Subject: [PATCH] parse URIs properly - parse file paths which are URIs using the uriparse crate - use deprecated fields as fallback for current fields - put back changedir before spawning child - pass proxy-client cwd as a fallback --- Cargo.lock | 17 +++++++ Cargo.toml | 1 + src/client.rs | 126 +++++++++++++++++++++++++++++++++++++----------- src/instance.rs | 3 +- src/lsp.rs | 14 ++++-- src/proxy.rs | 5 ++ 6 files changed, 133 insertions(+), 33 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 59d2fa2..ec5e9e3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -204,6 +204,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "getrandom" version = "0.2.10" @@ -412,6 +418,7 @@ dependencies = [ "toml", "tracing", "tracing-subscriber", + "uriparse", ] [[package]] @@ -741,6 +748,16 @@ version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "301abaae475aa91687eb82514b328ab47a211a533026cb25fc3e519b86adfc3c" +[[package]] +name = "uriparse" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0200d0fc04d809396c2ad43f3c95da3582a2556eba8d453c1087f4120ee352ff" +dependencies = [ + "fnv", + "lazy_static", +] + [[package]] name = "utf8parse" version = "0.2.1" diff --git a/Cargo.toml b/Cargo.toml index 46987b8..9ff8367 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,3 +19,4 @@ tokio = { version = "1.15.0", features = ["fs", "io-std", "io-util", "macros", " toml = "0.5.8" tracing = "0.1.39" tracing-subscriber = { version = "0.3.17", features = ["env-filter"] } +uriparse = "0.6.4" diff --git a/src/client.rs b/src/client.rs index a4734df..2c16923 100644 --- a/src/client.rs +++ b/src/client.rs @@ -1,14 +1,15 @@ use std::io::ErrorKind; use std::sync::Arc; -use anyhow::{bail, ensure, Context, Result}; +use anyhow::{bail, Context, Result}; use serde_json::Value; use tokio::io::BufReader; use tokio::net::tcp::{OwnedReadHalf, OwnedWriteHalf}; use tokio::net::TcpStream; use tokio::sync::{mpsc, Mutex}; use tokio::{select, task}; -use tracing::{debug, error, info, Instrument}; +use tracing::{debug, error, info, warn, Instrument}; +use uriparse::URI; use crate::instance::{self, Instance, InstanceKey, InstanceMap}; use crate::lsp::jsonrpc::{Message, RequestId, ResponseSuccess, Version}; @@ -50,31 +51,29 @@ pub async fn process( // TODO verify protocol version // Select the workspace root directory. - // - // Ideally we'd be looking up any server which has a superset of workspace - // folders active possibly adding transforming the `initialize` request into - // a few requests for adding workspace folders if the server supports it. - // Buuut let's just run with supporting single-folder workspaces only at - // first, it's probably the most common use-case anyway. - let folders = &init_params.workspace_folders; - ensure!( - folders.len() == 1, - "workspace must have exactly 1 folder, has {n}.\n{folders:#?}", - n = folders.len(), - ); - let folder = init_params.workspace_folders[0].clone(); + let workspace_root = { + let (scheme, _, mut path, _, _) = select_workspace_root(&init_params) + .context("could not get any workspace_root")? + .into_parts(); + if scheme != uriparse::Scheme::File { + bail!("only `file://` URIs are supported"); + } + path.normalize(false); + path.to_string() + }; // Get an language server instance for this client. let key = InstanceKey { server: options.server, args: options.args, - workspace_root: folder.uri, + workspace_root, }; let instance = instance::get_or_spawn(instance_map, key, init_params).await?; // Respond to client's `initialize` request using a response result from - // the first time this server instance was initialized, it might not be a - // response directly to our previous request but it should be similar since + // the first time this server instance was initialized, it might not be + // a response directly to our previous request but it should be hopefully + // similar if it comes from another instance of the same client. let res = ResponseSuccess { jsonrpc: Version, result: serde_json::to_value(instance.initialize_result()).unwrap(), @@ -111,22 +110,95 @@ pub async fn process( Ok(()) } +fn select_workspace_root(init_params: &InitializeParams) -> Result { + if init_params.workspace_folders.len() > 1 { + // TODO Ideally we'd be looking up any server which has a superset of + // workspace folders active possibly adding transforming the `initialize` + // request into a few requests for adding workspace folders if the + // server supports it. Buuut let's just run with supporting single-folder + // workspaces only at first, it's probably the most common use-case anyway. + warn!("initialize request with multiple workspace folders isn't supported"); + debug!(workspace_folders = ?init_params.workspace_folders); + } + + if init_params.workspace_folders.len() == 1 { + match URI::try_from(init_params.workspace_folders[0].uri.as_str()) + .context("parse initParams.workspaceFolders[0].uri") + { + Ok(root) => return Ok(root), + Err(err) => warn!(?err, "failed to parse URI"), + } + } + + assert!(init_params.workspace_folders.is_empty()); + + // Using the deprecated fields `rootPath` or `rootUri` as fallback + if let Some(root_uri) = &init_params.root_uri { + match URI::try_from(root_uri.as_str()).context("parse initParams.rootUri") { + Ok(root) => return Ok(root), + Err(err) => warn!(?err, "failed to parse URI"), + } + } + if let Some(root_path) = &init_params.root_path { + // `rootPath` doesn't have a schema but `Url` requires it to parse + match uriparse::Path::try_from(root_path.as_str()) + .map_err(uriparse::URIError::from) + .and_then(|path| { + URI::builder() + .with_scheme(uriparse::Scheme::File) + .with_path(path) + .build() + }) + .context("parse initParams.rootPath") + { + Ok(root) => return Ok(root), + Err(err) => warn!(?err, "failed to parse URI"), + } + } + + // Using the proxy `cwd` as fallback + if let Some(proxy_cwd) = init_params + .initialization_options + .as_ref() + .and_then(|opts| opts.lsp_mux.as_ref()) + .and_then(|lsp_mux| lsp_mux.cwd.as_ref()) + { + match uriparse::Path::try_from(proxy_cwd.as_str()) + .map_err(uriparse::URIError::from) + .and_then(|path| { + URI::builder() + .with_scheme(uriparse::Scheme::File) + .with_path(path) + .build() + }) + .context("parse initParams.initializationOptions.lspMux.cwd") + { + Ok(root) => return Ok(root), + Err(err) => warn!(?err, "failed to parse URI"), + } + } + + bail!("could not determine a suitable workspace_root"); +} + /// Receives messages from a channel and writes tem to the client input socket async fn input_task( mut rx: mpsc::Receiver, mut close_rx: mpsc::Receiver, mut writer: LspWriter, ) { - // Unlike the output task, here we first wait on the channel which is going to - // block until the language server sends a notification, however if we're the last - // client and have just closed the server is unlikely to send any. This results in the - // last client often falsely hanging while the gc task depends on the input channels being - // closed to detect a disconnected client. + // Unlike the output task, here we first wait on the channel which is going + // to block until the language server sends a notification, however if + // we're the last client and have just closed the server is unlikely to send + // any. This results in the last client often falsely hanging while the gc + // task depends on the input channels being closed to detect a disconnected + // client. // - // When a client sends a shutdown request we receive a message on the `close_rx`, send - // the reply and close the connection. If no shutdown request was received but the - // client closed `close_rx` channel will be dropped (unlike the normal rx channel which - // is shared) and the connection will close without sending any response. + // When a client sends a shutdown request we receive a message on the + // `close_rx`, send the reply and close the connection. If no shutdown + // request was received but the client closed `close_rx` channel will be + // dropped (unlike the normal rx channel which is shared) and the connection + // will close without sending any response. while let Some(message) = select! { message = close_rx.recv() => message, message = rx.recv() => message, diff --git a/src/instance.rs b/src/instance.rs index eb21be6..98edbe4 100644 --- a/src/instance.rs +++ b/src/instance.rs @@ -27,7 +27,6 @@ use crate::lsp::{InitializeParams, InitializeResult}; pub struct InstanceKey { pub server: String, pub args: Vec, - /// `initialize.params.workspace_folders[0].uri` pub workspace_root: String, } @@ -184,7 +183,7 @@ async fn spawn( ) -> Result> { let mut child = Command::new(&key.server) .args(&key.args) - // .current_dir(&key.workspace_root) + .current_dir(&key.workspace_root) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) diff --git a/src/lsp.rs b/src/lsp.rs index 37fff52..7f0aac2 100644 --- a/src/lsp.rs +++ b/src/lsp.rs @@ -121,6 +121,11 @@ pub struct LspMuxOptions { /// empty list if omited. #[serde(default = "Vec::new")] pub args: Vec, + + /// Current working directory of the proxy command. This is only used as + /// fallback if the client doesn't provide any workspace root. + #[serde(skip_serializing_if = "Option::is_none")] + pub cwd: Option, } #[derive(Serialize, Deserialize, Clone)] @@ -161,7 +166,8 @@ mod tests { "lspMux": { "version": "1", "server": "some-language-server", - "args": ["a", "b", "c"] + "args": ["a", "b", "c"], + "cwd": "/home/user", } })) } @@ -172,7 +178,7 @@ mod tests { "lspMux": { "version": "1", "server": "some-language-server", - "args": ["a", "b", "c"] + "args": ["a", "b", "c"], }, "lsp_mux": "not the right key", "lspmux": "also not it", @@ -190,7 +196,7 @@ mod tests { test::(json!({ "lspMux": { "server": "some-language-server", - "args": ["a", "b", "c"] + "args": ["a", "b", "c"], }, })) } @@ -201,7 +207,7 @@ mod tests { test::(json!({ "lspMux": { "version": "1", - "args": ["a", "b", "c"] + "args": ["a", "b", "c"], }, })) } diff --git a/src/proxy.rs b/src/proxy.rs index 83b042c..cc77dc8 100644 --- a/src/proxy.rs +++ b/src/proxy.rs @@ -1,3 +1,4 @@ +use std::env; use std::pin::Pin; use std::task::{Context, Poll}; @@ -53,6 +54,9 @@ impl AsyncWrite for Stdio { pub async fn run(server: String, args: Vec) -> Result<()> { let config = Config::load_or_default().await; + let cwd = env::current_dir() + .ok() + .and_then(|path| path.to_str().map(String::from)); let version = String::from("test"); // TODO use a real protocol version number let mut stream = TcpStream::connect(config.connect) @@ -78,6 +82,7 @@ pub async fn run(server: String, args: Vec) -> Result<()> { version, server, args, + cwd, }); req.params = serde_json::to_value(params).expect("BUG: invalid data");