implement proper lsp-workspace support

fix typo

Co-authored-by: LeoniePhiline <22329650+LeoniePhiline@users.noreply.github.com>
pull/6290/head^2
Pascal Kuthe 2 years ago committed by Blaž Hrastnik
parent 2d10a429eb
commit 5b3dd6a678

1
Cargo.lock generated

@ -1149,6 +1149,7 @@ dependencies = [
"helix-parsec", "helix-parsec",
"log", "log",
"lsp-types", "lsp-types",
"parking_lot",
"serde", "serde",
"serde_json", "serde_json",
"thiserror", "thiserror",

@ -64,7 +64,7 @@ These configuration keys are available:
| `grammar` | The tree-sitter grammar to use (defaults to the value of `name`) | | `grammar` | The tree-sitter grammar to use (defaults to the value of `name`) |
| `formatter` | The formatter for the language, it will take precedence over the lsp when defined. The formatter must be able to take the original file as input from stdin and write the formatted file to stdout | | `formatter` | The formatter for the language, it will take precedence over the lsp when defined. The formatter must be able to take the original file as input from stdin and write the formatted file to stdout |
| `text-width` | Maximum line length. Used for the `:reflow` command and soft-wrapping if `soft-wrap.wrap_at_text_width` is set, defaults to `editor.text-width` | | `text-width` | Maximum line length. Used for the `:reflow` command and soft-wrapping if `soft-wrap.wrap_at_text_width` is set, defaults to `editor.text-width` |
| `workspace-lsp-roots` | Directories relative to the workspace root that are treated as LSP roots. Should only be set in `.helix/config.toml`. Overwrites the setting of the same name in `config.toml` if set. | `` | | `workspace-lsp-roots` | Directories relative to the workspace root that are treated as LSP roots. Should only be set in `.helix/config.toml`. Overwrites the setting of the same name in `config.toml` if set. |
### File-type detection and the `file-types` key ### File-type detection and the `file-types` key

@ -9,7 +9,10 @@ pub fn default_lang_config() -> toml::Value {
/// User configured languages.toml file, merged with the default config. /// User configured languages.toml file, merged with the default config.
pub fn user_lang_config() -> Result<toml::Value, toml::de::Error> { pub fn user_lang_config() -> Result<toml::Value, toml::de::Error> {
let config = [crate::config_dir(), crate::find_workspace().join(".helix")] let config = [
crate::config_dir(),
crate::find_workspace().0.join(".helix"),
]
.into_iter() .into_iter()
.map(|path| path.join("languages.toml")) .map(|path| path.join("languages.toml"))
.filter_map(|file| { .filter_map(|file| {

@ -129,7 +129,7 @@ pub fn config_file() -> PathBuf {
} }
pub fn workspace_config_file() -> PathBuf { pub fn workspace_config_file() -> PathBuf {
find_workspace().join(".helix").join("config.toml") find_workspace().0.join(".helix").join("config.toml")
} }
pub fn lang_config_file() -> PathBuf { pub fn lang_config_file() -> PathBuf {
@ -283,14 +283,19 @@ mod merge_toml_tests {
} }
/// Finds the current workspace folder. /// Finds the current workspace folder.
/// Used as a ceiling dir for root resolve, for the filepicker and other related /// Used as a ceiling dir for LSP root resolution, the filepicker and potentially as a future filewatching root
pub fn find_workspace() -> PathBuf { ///
/// This function starts searching the FS upward from the CWD
/// and returns the first directory that contains either `.git` or `.helix`.
/// If no workspace was found returns (CWD, true).
/// Otherwise (workspace, false) is returned
pub fn find_workspace() -> (PathBuf, bool) {
let current_dir = std::env::current_dir().expect("unable to determine current directory"); let current_dir = std::env::current_dir().expect("unable to determine current directory");
for ancestor in current_dir.ancestors() { for ancestor in current_dir.ancestors() {
if ancestor.join(".git").exists() || ancestor.join(".helix").exists() { if ancestor.join(".git").exists() || ancestor.join(".helix").exists() {
return ancestor.to_owned(); return (ancestor.to_owned(), false);
} }
} }
current_dir (current_dir, true)
} }

@ -27,3 +27,4 @@ thiserror = "1.0"
tokio = { version = "1.27", features = ["rt", "rt-multi-thread", "io-util", "io-std", "time", "process", "macros", "fs", "parking_lot", "sync"] } tokio = { version = "1.27", features = ["rt", "rt-multi-thread", "io-util", "io-std", "time", "process", "macros", "fs", "parking_lot", "sync"] }
tokio-stream = "0.1.12" tokio-stream = "0.1.12"
which = "4.4" which = "4.4"
parking_lot = "0.12.1"

@ -1,13 +1,17 @@
use crate::{ use crate::{
find_root, jsonrpc, find_lsp_workspace, jsonrpc,
transport::{Payload, Transport}, transport::{Payload, Transport},
Call, Error, OffsetEncoding, Result, Call, Error, OffsetEncoding, Result,
}; };
use helix_core::{ChangeSet, Rope}; use helix_core::{find_workspace, ChangeSet, Rope};
use helix_loader::{self, VERSION_AND_GIT_HASH}; use helix_loader::{self, VERSION_AND_GIT_HASH};
use lsp::PositionEncodingKind; use lsp::{
notification::DidChangeWorkspaceFolders, DidChangeWorkspaceFoldersParams, OneOf,
PositionEncodingKind, WorkspaceFolder, WorkspaceFoldersChangeEvent,
};
use lsp_types as lsp; use lsp_types as lsp;
use parking_lot::Mutex;
use serde::Deserialize; use serde::Deserialize;
use serde_json::Value; use serde_json::Value;
use std::future::Future; use std::future::Future;
@ -26,6 +30,17 @@ use tokio::{
}, },
}; };
fn workspace_for_uri(uri: lsp::Url) -> WorkspaceFolder {
lsp::WorkspaceFolder {
name: uri
.path_segments()
.and_then(|segments| segments.last())
.map(|basename| basename.to_string())
.unwrap_or_default(),
uri,
}
}
#[derive(Debug)] #[derive(Debug)]
pub struct Client { pub struct Client {
id: usize, id: usize,
@ -36,11 +51,120 @@ pub struct Client {
config: Option<Value>, config: Option<Value>,
root_path: std::path::PathBuf, root_path: std::path::PathBuf,
root_uri: Option<lsp::Url>, root_uri: Option<lsp::Url>,
workspace_folders: Vec<lsp::WorkspaceFolder>, workspace_folders: Mutex<Vec<lsp::WorkspaceFolder>>,
initalize_notify: Arc<Notify>,
/// workspace folders added while the server is still initalizing
req_timeout: u64, req_timeout: u64,
} }
impl Client { impl Client {
pub fn try_add_doc(
self: &Arc<Self>,
root_markers: &[String],
manual_roots: &[PathBuf],
doc_path: Option<&std::path::PathBuf>,
may_support_workspace: bool,
) -> bool {
let (workspace, workspace_is_cwd) = find_workspace();
let root = find_lsp_workspace(
doc_path
.and_then(|x| x.parent().and_then(|x| x.to_str()))
.unwrap_or("."),
root_markers,
manual_roots,
&workspace,
workspace_is_cwd,
);
let root_uri = root
.as_ref()
.and_then(|root| lsp::Url::from_file_path(root).ok());
if self.root_path == root.unwrap_or(workspace)
|| root_uri.as_ref().map_or(false, |root_uri| {
self.workspace_folders
.lock()
.iter()
.any(|workspace| &workspace.uri == root_uri)
})
{
// workspace URI is already registered so we can use this client
return true;
}
// this server definitly doesn't support multiple workspace, no need to check capabilities
if !may_support_workspace {
return false;
}
let Some(capabilities) = self.capabilities.get() else {
let client = Arc::clone(self);
// initalization hasn't finished yet, deal with this new root later
// TODO: In the edgecase that a **new root** is added
// for an LSP that **doesn't support workspace_folders** before initaliation is finished
// the new roots are ignored.
// That particular edgecase would require retroactively spawning new LSP
// clients and therefore also require us to retroactively update the corresponding
// documents LSP client handle. It's doable but a pretty weird edgecase so let's
// wait and see if anyone ever runs into it.
tokio::spawn(async move {
client.initalize_notify.notified().await;
if let Some(workspace_folders_caps) = client
.capabilities()
.workspace
.as_ref()
.and_then(|cap| cap.workspace_folders.as_ref())
.filter(|cap| cap.supported.unwrap_or(false))
{
client.add_workspace_folder(
root_uri,
&workspace_folders_caps.change_notifications,
);
}
});
return true;
};
if let Some(workspace_folders_caps) = capabilities
.workspace
.as_ref()
.and_then(|cap| cap.workspace_folders.as_ref())
.filter(|cap| cap.supported.unwrap_or(false))
{
self.add_workspace_folder(root_uri, &workspace_folders_caps.change_notifications);
true
} else {
// the server doesn't support multi workspaces, we need a new client
false
}
}
fn add_workspace_folder(
&self,
root_uri: Option<lsp::Url>,
change_notifications: &Option<OneOf<bool, String>>,
) {
// root_uri is None just means that there isn't really any LSP workspace
// associated with this file. For servers that support multiple workspaces
// there is just one server so we can always just use that shared instance.
// No need to add a new workspace root here as there is no logical root for this file
// let the server deal with this
let Some(root_uri) = root_uri else {
return;
};
// server supports workspace folders, let's add the new root to the list
self.workspace_folders
.lock()
.push(workspace_for_uri(root_uri.clone()));
if &Some(OneOf::Left(false)) == change_notifications {
// server specifically opted out of DidWorkspaceChange notifications
// let's assume the server will request the workspace folders itself
// and that we can therefore reuse the client (but are done now)
return;
}
tokio::spawn(self.did_change_workspace(vec![workspace_for_uri(root_uri)], Vec::new()));
}
#[allow(clippy::type_complexity)] #[allow(clippy::type_complexity)]
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
pub fn start( pub fn start(
@ -76,30 +200,25 @@ impl Client {
let (server_rx, server_tx, initialize_notify) = let (server_rx, server_tx, initialize_notify) =
Transport::start(reader, writer, stderr, id); Transport::start(reader, writer, stderr, id);
let (workspace, workspace_is_cwd) = find_workspace();
let root_path = find_root( let root = find_lsp_workspace(
doc_path doc_path
.and_then(|x| x.parent().and_then(|x| x.to_str())) .and_then(|x| x.parent().and_then(|x| x.to_str()))
.unwrap_or("."), .unwrap_or("."),
root_markers, root_markers,
manual_roots, manual_roots,
&workspace,
workspace_is_cwd,
); );
let root_uri = lsp::Url::from_file_path(root_path.clone()).ok(); // `root_uri` and `workspace_folder` can be empty in case there is no workspace
// `root_url` can not, use `workspace` as a fallback
let root_path = root.clone().unwrap_or_else(|| workspace.clone());
let root_uri = root.and_then(|root| lsp::Url::from_file_path(root).ok());
// TODO: support multiple workspace folders
let workspace_folders = root_uri let workspace_folders = root_uri
.clone() .clone()
.map(|root| { .map(|root| vec![workspace_for_uri(root)])
vec![lsp::WorkspaceFolder {
name: root
.path_segments()
.and_then(|segments| segments.last())
.map(|basename| basename.to_string())
.unwrap_or_default(),
uri: root,
}]
})
.unwrap_or_default(); .unwrap_or_default();
let client = Self { let client = Self {
@ -110,10 +229,10 @@ impl Client {
capabilities: OnceCell::new(), capabilities: OnceCell::new(),
config, config,
req_timeout, req_timeout,
root_path, root_path,
root_uri, root_uri,
workspace_folders, workspace_folders: Mutex::new(workspace_folders),
initalize_notify: initialize_notify.clone(),
}; };
Ok((client, server_rx, initialize_notify)) Ok((client, server_rx, initialize_notify))
@ -169,8 +288,10 @@ impl Client {
self.config.as_ref() self.config.as_ref()
} }
pub fn workspace_folders(&self) -> &[lsp::WorkspaceFolder] { pub async fn workspace_folders(
&self.workspace_folders &self,
) -> parking_lot::MutexGuard<'_, Vec<lsp::WorkspaceFolder>> {
self.workspace_folders.lock()
} }
/// Execute a RPC request on the language server. /// Execute a RPC request on the language server.
@ -298,7 +419,7 @@ impl Client {
#[allow(deprecated)] #[allow(deprecated)]
let params = lsp::InitializeParams { let params = lsp::InitializeParams {
process_id: Some(std::process::id()), process_id: Some(std::process::id()),
workspace_folders: Some(self.workspace_folders.clone()), workspace_folders: Some(self.workspace_folders.lock().clone()),
// root_path is obsolete, but some clients like pyright still use it so we specify both. // root_path is obsolete, but some clients like pyright still use it so we specify both.
// clients will prefer _uri if possible // clients will prefer _uri if possible
root_path: self.root_path.to_str().map(|path| path.to_owned()), root_path: self.root_path.to_str().map(|path| path.to_owned()),
@ -469,6 +590,16 @@ impl Client {
) )
} }
pub fn did_change_workspace(
&self,
added: Vec<WorkspaceFolder>,
removed: Vec<WorkspaceFolder>,
) -> impl Future<Output = Result<()>> {
self.notify::<DidChangeWorkspaceFolders>(DidChangeWorkspaceFoldersParams {
event: WorkspaceFoldersChangeEvent { added, removed },
})
}
// ------------------------------------------------------------------------------------------- // -------------------------------------------------------------------------------------------
// Text document // Text document
// ------------------------------------------------------------------------------------------- // -------------------------------------------------------------------------------------------

@ -10,15 +10,12 @@ pub use lsp::{Position, Url};
pub use lsp_types as lsp; pub use lsp_types as lsp;
use futures_util::stream::select_all::SelectAll; use futures_util::stream::select_all::SelectAll;
use helix_core::{ use helix_core::syntax::{LanguageConfiguration, LanguageServerConfiguration};
find_workspace,
syntax::{LanguageConfiguration, LanguageServerConfiguration},
};
use tokio::sync::mpsc::UnboundedReceiver; use tokio::sync::mpsc::UnboundedReceiver;
use std::{ use std::{
collections::{hash_map::Entry, HashMap}, collections::{hash_map::Entry, HashMap},
path::PathBuf, path::{Path, PathBuf},
sync::{ sync::{
atomic::{AtomicUsize, Ordering}, atomic::{AtomicUsize, Ordering},
Arc, Arc,
@ -609,7 +606,7 @@ impl Notification {
#[derive(Debug)] #[derive(Debug)]
pub struct Registry { pub struct Registry {
inner: HashMap<LanguageId, (usize, Arc<Client>)>, inner: HashMap<LanguageId, Vec<(usize, Arc<Client>)>>,
counter: AtomicUsize, counter: AtomicUsize,
pub incoming: SelectAll<UnboundedReceiverStream<(usize, Call)>>, pub incoming: SelectAll<UnboundedReceiverStream<(usize, Call)>>,
@ -633,12 +630,16 @@ impl Registry {
pub fn get_by_id(&self, id: usize) -> Option<&Client> { pub fn get_by_id(&self, id: usize) -> Option<&Client> {
self.inner self.inner
.values() .values()
.flatten()
.find(|(client_id, _)| client_id == &id) .find(|(client_id, _)| client_id == &id)
.map(|(_, client)| client.as_ref()) .map(|(_, client)| client.as_ref())
} }
pub fn remove_by_id(&mut self, id: usize) { pub fn remove_by_id(&mut self, id: usize) {
self.inner.retain(|_, (client_id, _)| client_id != &id) self.inner.retain(|_, clients| {
clients.retain(|&(client_id, _)| client_id != id);
!clients.is_empty()
})
} }
pub fn restart( pub fn restart(
@ -664,11 +665,13 @@ impl Registry {
start_client(id, language_config, config, doc_path, root_dirs)?; start_client(id, language_config, config, doc_path, root_dirs)?;
self.incoming.push(UnboundedReceiverStream::new(incoming)); self.incoming.push(UnboundedReceiverStream::new(incoming));
let (_, old_client) = entry.insert((id, client.clone())); let old_clients = entry.insert(vec![(id, client.clone())]);
for (_, old_client) in old_clients {
tokio::spawn(async move { tokio::spawn(async move {
let _ = old_client.force_shutdown().await; let _ = old_client.force_shutdown().await;
}); });
}
Ok(Some(client)) Ok(Some(client))
} }
@ -678,12 +681,14 @@ impl Registry {
pub fn stop(&mut self, language_config: &LanguageConfiguration) { pub fn stop(&mut self, language_config: &LanguageConfiguration) {
let scope = language_config.scope.clone(); let scope = language_config.scope.clone();
if let Some((_, client)) = self.inner.remove(&scope) { if let Some(clients) = self.inner.remove(&scope) {
for (_, client) in clients {
tokio::spawn(async move { tokio::spawn(async move {
let _ = client.force_shutdown().await; let _ = client.force_shutdown().await;
}); });
} }
} }
}
pub fn get( pub fn get(
&mut self, &mut self,
@ -696,24 +701,25 @@ impl Registry {
None => return Ok(None), None => return Ok(None),
}; };
match self.inner.entry(language_config.scope.clone()) { let clients = self.inner.entry(language_config.scope.clone()).or_default();
Entry::Occupied(entry) => Ok(Some(entry.get().1.clone())), // check if we already have a client for this documents root that we can reuse
Entry::Vacant(entry) => { if let Some((_, client)) = clients.iter_mut().enumerate().find(|(i, (_, client))| {
client.try_add_doc(&language_config.roots, root_dirs, doc_path, *i == 0)
}) {
return Ok(Some(client.1.clone()));
}
// initialize a new client // initialize a new client
let id = self.counter.fetch_add(1, Ordering::Relaxed); let id = self.counter.fetch_add(1, Ordering::Relaxed);
let NewClientResult(client, incoming) = let NewClientResult(client, incoming) =
start_client(id, language_config, config, doc_path, root_dirs)?; start_client(id, language_config, config, doc_path, root_dirs)?;
clients.push((id, client.clone()));
self.incoming.push(UnboundedReceiverStream::new(incoming)); self.incoming.push(UnboundedReceiverStream::new(incoming));
entry.insert((id, client.clone()));
Ok(Some(client)) Ok(Some(client))
} }
}
}
pub fn iter_clients(&self) -> impl Iterator<Item = &Arc<Client>> { pub fn iter_clients(&self) -> impl Iterator<Item = &Arc<Client>> {
self.inner.values().map(|(_, client)| client) self.inner.values().flatten().map(|(_, client)| client)
} }
} }
@ -850,16 +856,23 @@ fn start_client(
Ok(NewClientResult(client, incoming)) Ok(NewClientResult(client, incoming))
} }
/// Find an LSP root of a file using the following mechansim: /// Find an LSP workspace of a file using the following mechanism:
/// * start at `file` (either an absolute path or relative to CWD) /// * if the file is outside `workspace` return `None`
/// * find the top most directory containing a root_marker /// * start at `file` and search the file tree upward
/// * inside the current workspace /// * stop the search at the first `root_dirs` entry that contains `file`
/// * stop the search at the first root_dir that contains `file` or the workspace (obtained from `helix_core::find_workspace`) /// * if no `root_dirs` matchs `file` stop at workspace
/// * root_dirs only apply inside the workspace. For files outside of the workspace they are ignored /// * Returns the top most directory that contains a `root_marker`
/// * outside the current workspace: keep searching to the top of the file hiearchy /// * If no root marker and we stopped at a `root_dirs` entry, return the directory we stopped at
pub fn find_root(file: &str, root_markers: &[String], root_dirs: &[PathBuf]) -> PathBuf { /// * If we stopped at `workspace` instead and `workspace_is_cwd == false` return `None`
/// * If we stopped at `workspace` instead and `workspace_is_cwd == true` return `workspace`
pub fn find_lsp_workspace(
file: &str,
root_markers: &[String],
root_dirs: &[PathBuf],
workspace: &Path,
workspace_is_cwd: bool,
) -> Option<PathBuf> {
let file = std::path::Path::new(file); let file = std::path::Path::new(file);
let workspace = find_workspace();
let file = if file.is_absolute() { let file = if file.is_absolute() {
file.to_path_buf() file.to_path_buf()
} else { } else {
@ -867,7 +880,9 @@ pub fn find_root(file: &str, root_markers: &[String], root_dirs: &[PathBuf]) ->
current_dir.join(file) current_dir.join(file)
}; };
let inside_workspace = file.strip_prefix(&workspace).is_ok(); if !file.starts_with(workspace) {
return None;
}
let mut top_marker = None; let mut top_marker = None;
for ancestor in file.ancestors() { for ancestor in file.ancestors() {
@ -878,18 +893,25 @@ pub fn find_root(file: &str, root_markers: &[String], root_dirs: &[PathBuf]) ->
top_marker = Some(ancestor); top_marker = Some(ancestor);
} }
if inside_workspace if root_dirs
&& (ancestor == workspace
|| root_dirs
.iter() .iter()
.any(|root_dir| root_dir == ancestor.strip_prefix(&workspace).unwrap())) .any(|root_dir| root_dir == ancestor.strip_prefix(workspace).unwrap())
{ {
return top_marker.unwrap_or(ancestor).to_owned(); // if the worskapce is the cwd do not search any higher for workspaces
// but specify
return Some(top_marker.unwrap_or(workspace).to_owned());
}
if ancestor == workspace {
// if the workspace is the CWD, let the LSP decide what the workspace
// is
return top_marker
.or_else(|| (!workspace_is_cwd).then_some(workspace))
.map(Path::to_owned);
} }
} }
// If no root was found use the workspace as a fallback debug_assert!(false, "workspace must be an ancestor of <file>");
workspace None
} }
#[cfg(test)] #[cfg(test)]

@ -1018,7 +1018,7 @@ impl Application {
let language_server = let language_server =
self.editor.language_servers.get_by_id(server_id).unwrap(); self.editor.language_servers.get_by_id(server_id).unwrap();
Ok(json!(language_server.workspace_folders())) Ok(json!(&*language_server.workspace_folders().await))
} }
Ok(MethodCall::WorkspaceConfiguration(params)) => { Ok(MethodCall::WorkspaceConfiguration(params)) => {
let result: Vec<_> = params let result: Vec<_> = params

@ -2419,7 +2419,7 @@ fn append_mode(cx: &mut Context) {
} }
fn file_picker(cx: &mut Context) { fn file_picker(cx: &mut Context) {
let root = find_workspace(); let root = find_workspace().0;
let picker = ui::file_picker(root, &cx.editor.config()); let picker = ui::file_picker(root, &cx.editor.config());
cx.push_layer(Box::new(overlayed(picker))); cx.push_layer(Box::new(overlayed(picker)));
} }

Loading…
Cancel
Save