on master help sign in
implement proper lsp-workspace support

fix typo Co-authored-by: LeoniePhiline <22329650+LeoniePhiline@users.noreply.github.com>

commit5b3dd6a678ba138ea21d7d5dd8d3c8a53c7a6d3b authorPascal Kuthe <pascal.kuthe@semimod.de> dateTue 7 Feb 2023, 15:59 (4 years ago) parent2d10a42
9 files changed+270−107
Cargo.lock+1 book/src/languages.md+1−1 helix-loader/src/config.rs+32−29 helix-loader/src/lib.rs+10−5 helix-lsp/Cargo.toml+1 helix-lsp/src/client.rs+154−23 helix-lsp/src/lib.rs+69−47 helix-term/src/application.rs+1−1 helix-term/src/commands.rs+1−1
Cargo.lock+1
@@ -1149,6 +1149,7 @@ dependencies = [
11491149 "helix-parsec",
11501150 "log",
11511151 "lsp-types",
1152+ "parking_lot",
11521153 "serde",
11531154 "serde_json",
11541155 "thiserror",
book/src/languages.md+1−1
@@ -64,7 +64,7 @@ These configuration keys are available:
6464| `grammar` | The tree-sitter grammar to use (defaults to the value of `name`) |
6565| `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 |
6666| `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` |
67−| `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. | `` |
67+| `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. |
6868
6969### File-type detection and the `file-types` key
7070
helix-loader/src/config.rs+32−29
@@ -9,35 +9,38 @@ pub fn default_lang_config() -> toml::Value {
99
1010/// User configured languages.toml file, merged with the default config.
1111pub fn user_lang_config() -> Result<toml::Value, toml::de::Error> {
12− let config = [crate::config_dir(), crate::find_workspace().join(".helix")]
13− .into_iter()
14− .map(|path| path.join("languages.toml"))
15− .filter_map(|file| {
16− std::fs::read_to_string(file)
17− .map(|config| toml::from_str(&config))
18− .ok()
19− })
20− .collect::<Result<Vec<_>, _>>()?
21− .into_iter()
22− .fold(default_lang_config(), |a, b| {
23− // combines for example
24− // b:
25− // [[language]]
26− // name = "toml"
27− // language-server = { command = "taplo", args = ["lsp", "stdio"] }
28− //
29− // a:
30− // [[language]]
31− // language-server = { command = "/usr/bin/taplo" }
32− //
33− // into:
34− // [[language]]
35− // name = "toml"
36− // language-server = { command = "/usr/bin/taplo" }
37− //
38− // thus it overrides the third depth-level of b with values of a if they exist, but otherwise merges their values
39− crate::merge_toml_values(a, b, 3)
40− });
12+ let config = [
13+ crate::config_dir(),
14+ crate::find_workspace().0.join(".helix"),
15+ ]
16+ .into_iter()
17+ .map(|path| path.join("languages.toml"))
18+ .filter_map(|file| {
19+ std::fs::read_to_string(file)
20+ .map(|config| toml::from_str(&config))
21+ .ok()
22+ })
23+ .collect::<Result<Vec<_>, _>>()?
24+ .into_iter()
25+ .fold(default_lang_config(), |a, b| {
26+ // combines for example
27+ // b:
28+ // [[language]]
29+ // name = "toml"
30+ // language-server = { command = "taplo", args = ["lsp", "stdio"] }
31+ //
32+ // a:
33+ // [[language]]
34+ // language-server = { command = "/usr/bin/taplo" }
35+ //
36+ // into:
37+ // [[language]]
38+ // name = "toml"
39+ // language-server = { command = "/usr/bin/taplo" }
40+ //
41+ // thus it overrides the third depth-level of b with values of a if they exist, but otherwise merges their values
42+ crate::merge_toml_values(a, b, 3)
43+ });
4144
4245 Ok(config)
4346}
helix-loader/src/lib.rs+10−5
@@ -129,7 +129,7 @@ pub fn config_file() -> PathBuf {
129129}
130130
131131pub fn workspace_config_file() -> PathBuf {
132− find_workspace().join(".helix").join("config.toml")
132+ find_workspace().0.join(".helix").join("config.toml")
133133}
134134
135135pub fn lang_config_file() -> PathBuf {
@@ -283,14 +283,19 @@ mod merge_toml_tests {
283283}
284284
285285/// Finds the current workspace folder.
286−/// Used as a ceiling dir for root resolve, for the filepicker and other related
287−pub fn find_workspace() -> PathBuf {
286+/// Used as a ceiling dir for LSP root resolution, the filepicker and potentially as a future filewatching root
287+///
288+/// This function starts searching the FS upward from the CWD
289+/// and returns the first directory that contains either `.git` or `.helix`.
290+/// If no workspace was found returns (CWD, true).
291+/// Otherwise (workspace, false) is returned
292+pub fn find_workspace() -> (PathBuf, bool) {
288293 let current_dir = std::env::current_dir().expect("unable to determine current directory");
289294 for ancestor in current_dir.ancestors() {
290295 if ancestor.join(".git").exists() || ancestor.join(".helix").exists() {
291− return ancestor.to_owned();
296+ return (ancestor.to_owned(), false);
292297 }
293298 }
294299
295− current_dir
300+ (current_dir, true)
296301}
helix-lsp/Cargo.toml+1
@@ -27,3 +27,4 @@ thiserror = "1.0"
2727tokio = { version = "1.27", features = ["rt", "rt-multi-thread", "io-util", "io-std", "time", "process", "macros", "fs", "parking_lot", "sync"] }
2828tokio-stream = "0.1.12"
2929which = "4.4"
30+parking_lot = "0.12.1"
helix-lsp/src/client.rs+154−23
@@ -1,13 +1,17 @@
11use crate::{
2− find_root, jsonrpc,
2+ find_lsp_workspace, jsonrpc,
33 transport::{Payload, Transport},
44 Call, Error, OffsetEncoding, Result,
55};
66
7−use helix_core::{ChangeSet, Rope};
7+use helix_core::{find_workspace, ChangeSet, Rope};
88use helix_loader::{self, VERSION_AND_GIT_HASH};
9−use lsp::PositionEncodingKind;
9+use lsp::{
10+ notification::DidChangeWorkspaceFolders, DidChangeWorkspaceFoldersParams, OneOf,
11+ PositionEncodingKind, WorkspaceFolder, WorkspaceFoldersChangeEvent,
12+};
1013use lsp_types as lsp;
14+use parking_lot::Mutex;
1115use serde::Deserialize;
1216use serde_json::Value;
1317use std::future::Future;
@@ -26,6 +30,17 @@ use tokio::{
2630 },
2731};
2832
33+fn workspace_for_uri(uri: lsp::Url) -> WorkspaceFolder {
34+ lsp::WorkspaceFolder {
35+ name: uri
36+ .path_segments()
37+ .and_then(|segments| segments.last())
38+ .map(|basename| basename.to_string())
39+ .unwrap_or_default(),
40+ uri,
41+ }
42+}
43+
2944#[derive(Debug)]
3045pub struct Client {
3146 id: usize,
@@ -36,11 +51,120 @@ pub struct Client {
3651 config: Option<Value>,
3752 root_path: std::path::PathBuf,
3853 root_uri: Option<lsp::Url>,
39− workspace_folders: Vec<lsp::WorkspaceFolder>,
54+ workspace_folders: Mutex<Vec<lsp::WorkspaceFolder>>,
55+ initalize_notify: Arc<Notify>,
56+ /// workspace folders added while the server is still initalizing
4057 req_timeout: u64,
4158}
4259
4360impl Client {
61+ pub fn try_add_doc(
62+ self: &Arc<Self>,
63+ root_markers: &[String],
64+ manual_roots: &[PathBuf],
65+ doc_path: Option<&std::path::PathBuf>,
66+ may_support_workspace: bool,
67+ ) -> bool {
68+ let (workspace, workspace_is_cwd) = find_workspace();
69+ let root = find_lsp_workspace(
70+ doc_path
71+ .and_then(|x| x.parent().and_then(|x| x.to_str()))
72+ .unwrap_or("."),
73+ root_markers,
74+ manual_roots,
75+ &workspace,
76+ workspace_is_cwd,
77+ );
78+ let root_uri = root
79+ .as_ref()
80+ .and_then(|root| lsp::Url::from_file_path(root).ok());
81+
82+ if self.root_path == root.unwrap_or(workspace)
83+ || root_uri.as_ref().map_or(false, |root_uri| {
84+ self.workspace_folders
85+ .lock()
86+ .iter()
87+ .any(|workspace| &workspace.uri == root_uri)
88+ })
89+ {
90+ // workspace URI is already registered so we can use this client
91+ return true;
92+ }
93+
94+ // this server definitly doesn't support multiple workspace, no need to check capabilities
95+ if !may_support_workspace {
96+ return false;
97+ }
98+
99+ let Some(capabilities) = self.capabilities.get() else {
100+ let client = Arc::clone(self);
101+ // initalization hasn't finished yet, deal with this new root later
102+ // TODO: In the edgecase that a **new root** is added
103+ // for an LSP that **doesn't support workspace_folders** before initaliation is finished
104+ // the new roots are ignored.
105+ // That particular edgecase would require retroactively spawning new LSP
106+ // clients and therefore also require us to retroactively update the corresponding
107+ // documents LSP client handle. It's doable but a pretty weird edgecase so let's
108+ // wait and see if anyone ever runs into it.
109+ tokio::spawn(async move {
110+ client.initalize_notify.notified().await;
111+ if let Some(workspace_folders_caps) = client
112+ .capabilities()
113+ .workspace
114+ .as_ref()
115+ .and_then(|cap| cap.workspace_folders.as_ref())
116+ .filter(|cap| cap.supported.unwrap_or(false))
117+ {
118+ client.add_workspace_folder(
119+ root_uri,
120+ &workspace_folders_caps.change_notifications,
121+ );
122+ }
123+ });
124+ return true;
125+ };
126+
127+ if let Some(workspace_folders_caps) = capabilities
128+ .workspace
129+ .as_ref()
130+ .and_then(|cap| cap.workspace_folders.as_ref())
131+ .filter(|cap| cap.supported.unwrap_or(false))
132+ {
133+ self.add_workspace_folder(root_uri, &workspace_folders_caps.change_notifications);
134+ true
135+ } else {
136+ // the server doesn't support multi workspaces, we need a new client
137+ false
138+ }
139+ }
140+
141+ fn add_workspace_folder(
142+ &self,
143+ root_uri: Option<lsp::Url>,
144+ change_notifications: &Option<OneOf<bool, String>>,
145+ ) {
146+ // root_uri is None just means that there isn't really any LSP workspace
147+ // associated with this file. For servers that support multiple workspaces
148+ // there is just one server so we can always just use that shared instance.
149+ // No need to add a new workspace root here as there is no logical root for this file
150+ // let the server deal with this
151+ let Some(root_uri) = root_uri else {
152+ return;
153+ };
154+
155+ // server supports workspace folders, let's add the new root to the list
156+ self.workspace_folders
157+ .lock()
158+ .push(workspace_for_uri(root_uri.clone()));
159+ if &Some(OneOf::Left(false)) == change_notifications {
160+ // server specifically opted out of DidWorkspaceChange notifications
161+ // let's assume the server will request the workspace folders itself
162+ // and that we can therefore reuse the client (but are done now)
163+ return;
164+ }
165+ tokio::spawn(self.did_change_workspace(vec![workspace_for_uri(root_uri)], Vec::new()));
166+ }
167+
44168 #[allow(clippy::type_complexity)]
45169 #[allow(clippy::too_many_arguments)]
46170 pub fn start(
@@ -76,30 +200,25 @@ impl Client {
76200
77201 let (server_rx, server_tx, initialize_notify) =
78202 Transport::start(reader, writer, stderr, id);
79−
80− let root_path = find_root(
203+ let (workspace, workspace_is_cwd) = find_workspace();
204+ let root = find_lsp_workspace(
81205 doc_path
82206 .and_then(|x| x.parent().and_then(|x| x.to_str()))
83207 .unwrap_or("."),
84208 root_markers,
85209 manual_roots,
210+ &workspace,
211+ workspace_is_cwd,
86212 );
87213
88− let root_uri = lsp::Url::from_file_path(root_path.clone()).ok();
214+ // `root_uri` and `workspace_folder` can be empty in case there is no workspace
215+ // `root_url` can not, use `workspace` as a fallback
216+ let root_path = root.clone().unwrap_or_else(|| workspace.clone());
217+ let root_uri = root.and_then(|root| lsp::Url::from_file_path(root).ok());
89218
90− // TODO: support multiple workspace folders
91219 let workspace_folders = root_uri
92220 .clone()
93− .map(|root| {
94− vec![lsp::WorkspaceFolder {
95− name: root
96− .path_segments()
97− .and_then(|segments| segments.last())
98− .map(|basename| basename.to_string())
99− .unwrap_or_default(),
100− uri: root,
101− }]
102− })
221+ .map(|root| vec![workspace_for_uri(root)])
103222 .unwrap_or_default();
104223
105224 let client = Self {
@@ -110,10 +229,10 @@ impl Client {
110229 capabilities: OnceCell::new(),
111230 config,
112231 req_timeout,
113−
114232 root_path,
115233 root_uri,
116− workspace_folders,
234+ workspace_folders: Mutex::new(workspace_folders),
235+ initalize_notify: initialize_notify.clone(),
117236 };
118237
119238 Ok((client, server_rx, initialize_notify))
@@ -169,8 +288,10 @@ impl Client {
169288 self.config.as_ref()
170289 }
171290
172− pub fn workspace_folders(&self) -> &[lsp::WorkspaceFolder] {
173− &self.workspace_folders
291+ pub async fn workspace_folders(
292+ &self,
293+ ) -> parking_lot::MutexGuard<'_, Vec<lsp::WorkspaceFolder>> {
294+ self.workspace_folders.lock()
174295 }
175296
176297 /// Execute a RPC request on the language server.
@@ -298,7 +419,7 @@ impl Client {
298419 #[allow(deprecated)]
299420 let params = lsp::InitializeParams {
300421 process_id: Some(std::process::id()),
301− workspace_folders: Some(self.workspace_folders.clone()),
422+ workspace_folders: Some(self.workspace_folders.lock().clone()),
302423 // root_path is obsolete, but some clients like pyright still use it so we specify both.
303424 // clients will prefer _uri if possible
304425 root_path: self.root_path.to_str().map(|path| path.to_owned()),
@@ -469,6 +590,16 @@ impl Client {
469590 )
470591 }
471592
593+ pub fn did_change_workspace(
594+ &self,
595+ added: Vec<WorkspaceFolder>,
596+ removed: Vec<WorkspaceFolder>,
597+ ) -> impl Future<Output = Result<()>> {
598+ self.notify::<DidChangeWorkspaceFolders>(DidChangeWorkspaceFoldersParams {
599+ event: WorkspaceFoldersChangeEvent { added, removed },
600+ })
601+ }
602+
472603 // -------------------------------------------------------------------------------------------
473604 // Text document
474605 // -------------------------------------------------------------------------------------------
helix-lsp/src/lib.rs+69−47
@@ -10,15 +10,12 @@ pub use lsp::{Position, Url};
1010pub use lsp_types as lsp;
1111
1212use futures_util::stream::select_all::SelectAll;
13−use helix_core::{
14− find_workspace,
15− syntax::{LanguageConfiguration, LanguageServerConfiguration},
16−};
13+use helix_core::syntax::{LanguageConfiguration, LanguageServerConfiguration};
1714use tokio::sync::mpsc::UnboundedReceiver;
1815
1916use std::{
2017 collections::{hash_map::Entry, HashMap},
21− path::PathBuf,
18+ path::{Path, PathBuf},
2219 sync::{
2320 atomic::{AtomicUsize, Ordering},
2421 Arc,
@@ -609,7 +606,7 @@ impl Notification {
609606
610607#[derive(Debug)]
611608pub struct Registry {
612− inner: HashMap<LanguageId, (usize, Arc<Client>)>,
609+ inner: HashMap<LanguageId, Vec<(usize, Arc<Client>)>>,
613610
614611 counter: AtomicUsize,
615612 pub incoming: SelectAll<UnboundedReceiverStream<(usize, Call)>>,
@@ -633,12 +630,16 @@ impl Registry {
633630 pub fn get_by_id(&self, id: usize) -> Option<&Client> {
634631 self.inner
635632 .values()
633+ .flatten()
636634 .find(|(client_id, _)| client_id == &id)
637635 .map(|(_, client)| client.as_ref())
638636 }
639637
640638 pub fn remove_by_id(&mut self, id: usize) {
641− self.inner.retain(|_, (client_id, _)| client_id != &id)
639+ self.inner.retain(|_, clients| {
640+ clients.retain(|&(client_id, _)| client_id != id);
641+ !clients.is_empty()
642+ })
642643 }
643644
644645 pub fn restart(
@@ -664,11 +665,13 @@ impl Registry {
664665 start_client(id, language_config, config, doc_path, root_dirs)?;
665666 self.incoming.push(UnboundedReceiverStream::new(incoming));
666667
667− let (_, old_client) = entry.insert((id, client.clone()));
668+ let old_clients = entry.insert(vec![(id, client.clone())]);
668669
669− tokio::spawn(async move {
670− let _ = old_client.force_shutdown().await;
671− });
670+ for (_, old_client) in old_clients {
671+ tokio::spawn(async move {
672+ let _ = old_client.force_shutdown().await;
673+ });
674+ }
672675
673676 Ok(Some(client))
674677 }
@@ -678,10 +681,12 @@ impl Registry {
678681 pub fn stop(&mut self, language_config: &LanguageConfiguration) {
679682 let scope = language_config.scope.clone();
680683
681− if let Some((_, client)) = self.inner.remove(&scope) {
682− tokio::spawn(async move {
683− let _ = client.force_shutdown().await;
684− });
684+ if let Some(clients) = self.inner.remove(&scope) {
685+ for (_, client) in clients {
686+ tokio::spawn(async move {
687+ let _ = client.force_shutdown().await;
688+ });
689+ }
685690 }
686691 }
687692
@@ -696,24 +701,25 @@ impl Registry {
696701 None => return Ok(None),
697702 };
698703
699− match self.inner.entry(language_config.scope.clone()) {
700− Entry::Occupied(entry) => Ok(Some(entry.get().1.clone())),
701− Entry::Vacant(entry) => {
702− // initialize a new client
703− let id = self.counter.fetch_add(1, Ordering::Relaxed);
704−
705− let NewClientResult(client, incoming) =
706− start_client(id, language_config, config, doc_path, root_dirs)?;
707− self.incoming.push(UnboundedReceiverStream::new(incoming));
708−
709− entry.insert((id, client.clone()));
710− Ok(Some(client))
711− }
704+ let clients = self.inner.entry(language_config.scope.clone()).or_default();
705+ // check if we already have a client for this documents root that we can reuse
706+ if let Some((_, client)) = clients.iter_mut().enumerate().find(|(i, (_, client))| {
707+ client.try_add_doc(&language_config.roots, root_dirs, doc_path, *i == 0)
708+ }) {
709+ return Ok(Some(client.1.clone()));
712710 }
711+ // initialize a new client
712+ let id = self.counter.fetch_add(1, Ordering::Relaxed);
713+
714+ let NewClientResult(client, incoming) =
715+ start_client(id, language_config, config, doc_path, root_dirs)?;
716+ clients.push((id, client.clone()));
717+ self.incoming.push(UnboundedReceiverStream::new(incoming));
718+ Ok(Some(client))
713719 }
714720
715721 pub fn iter_clients(&self) -> impl Iterator<Item = &Arc<Client>> {
716− self.inner.values().map(|(_, client)| client)
722+ self.inner.values().flatten().map(|(_, client)| client)
717723 }
718724}
719725
@@ -850,16 +856,23 @@ fn start_client(
850856 Ok(NewClientResult(client, incoming))
851857}
852858
853−/// Find an LSP root of a file using the following mechansim:
854−/// * start at `file` (either an absolute path or relative to CWD)
855−/// * find the top most directory containing a root_marker
856−/// * inside the current workspace
857−/// * stop the search at the first root_dir that contains `file` or the workspace (obtained from `helix_core::find_workspace`)
858−/// * root_dirs only apply inside the workspace. For files outside of the workspace they are ignored
859−/// * outside the current workspace: keep searching to the top of the file hiearchy
860−pub fn find_root(file: &str, root_markers: &[String], root_dirs: &[PathBuf]) -> PathBuf {
859+/// Find an LSP workspace of a file using the following mechanism:
860+/// * if the file is outside `workspace` return `None`
861+/// * start at `file` and search the file tree upward
862+/// * stop the search at the first `root_dirs` entry that contains `file`
863+/// * if no `root_dirs` matchs `file` stop at workspace
864+/// * Returns the top most directory that contains a `root_marker`
865+/// * If no root marker and we stopped at a `root_dirs` entry, return the directory we stopped at
866+/// * If we stopped at `workspace` instead and `workspace_is_cwd == false` return `None`
867+/// * If we stopped at `workspace` instead and `workspace_is_cwd == true` return `workspace`
868+pub fn find_lsp_workspace(
869+ file: &str,
870+ root_markers: &[String],
871+ root_dirs: &[PathBuf],
872+ workspace: &Path,
873+ workspace_is_cwd: bool,
874+) -> Option<PathBuf> {
861875 let file = std::path::Path::new(file);
862− let workspace = find_workspace();
863876 let file = if file.is_absolute() {
864877 file.to_path_buf()
865878 } else {
@@ -867,7 +880,9 @@ pub fn find_root(file: &str, root_markers: &[String], root_dirs: &[PathBuf]) ->
867880 current_dir.join(file)
868881 };
869882
870− let inside_workspace = file.strip_prefix(&workspace).is_ok();
883+ if !file.starts_with(workspace) {
884+ return None;
885+ }
871886
872887 let mut top_marker = None;
873888 for ancestor in file.ancestors() {
@@ -878,18 +893,25 @@ pub fn find_root(file: &str, root_markers: &[String], root_dirs: &[PathBuf]) ->
878893 top_marker = Some(ancestor);
879894 }
880895
881− if inside_workspace
882− && (ancestor == workspace
883− || root_dirs
884− .iter()
885− .any(|root_dir| root_dir == ancestor.strip_prefix(&workspace).unwrap()))
896+ if root_dirs
897+ .iter()
898+ .any(|root_dir| root_dir == ancestor.strip_prefix(workspace).unwrap())
886899 {
887− return top_marker.unwrap_or(ancestor).to_owned();
900+ // if the worskapce is the cwd do not search any higher for workspaces
901+ // but specify
902+ return Some(top_marker.unwrap_or(workspace).to_owned());
903+ }
904+ if ancestor == workspace {
905+ // if the workspace is the CWD, let the LSP decide what the workspace
906+ // is
907+ return top_marker
908+ .or_else(|| (!workspace_is_cwd).then_some(workspace))
909+ .map(Path::to_owned);
888910 }
889911 }
890912
891− // If no root was found use the workspace as a fallback
892− workspace
913+ debug_assert!(false, "workspace must be an ancestor of <file>");
914+ None
893915}
894916
895917#[cfg(test)]
helix-term/src/application.rs+1−1
@@ -1018,7 +1018,7 @@ impl Application {
10181018 let language_server =
10191019 self.editor.language_servers.get_by_id(server_id).unwrap();
10201020
1021− Ok(json!(language_server.workspace_folders()))
1021+ Ok(json!(&*language_server.workspace_folders().await))
10221022 }
10231023 Ok(MethodCall::WorkspaceConfiguration(params)) => {
10241024 let result: Vec<_> = params
helix-term/src/commands.rs+1−1
@@ -2419,7 +2419,7 @@ fn append_mode(cx: &mut Context) {
24192419}
24202420
24212421fn file_picker(cx: &mut Context) {
2422− let root = find_workspace();
2422+ let root = find_workspace().0;
24232423 let picker = ui::file_picker(root, &cx.editor.config());
24242424 cx.push_layer(Box::new(overlayed(picker)));
24252425}