Respect LSP servers watch glob patterns

Max Brunsfeld created

Change summary

Cargo.lock                          |   5 
crates/project/Cargo.toml           |   1 
crates/project/src/lsp_glob_set.rs  | 121 +++++++++++++++++++++++++++++++
crates/project/src/project.rs       |  86 +++++++++++++++++----
crates/project/src/project_tests.rs |  26 ++++++
5 files changed, 218 insertions(+), 21 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -2591,9 +2591,9 @@ dependencies = [
 
 [[package]]
 name = "glob"
-version = "0.3.0"
+version = "0.3.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9b919933a397b79c37e33b77bb2aa3dc8eb6e165ad809e58ff75bc7db2e34574"
+checksum = "d2fabcfbdc87f4758337ca535fb41a6d701b65693ce38287d856d1674551ec9b"
 
 [[package]]
 name = "globset"
@@ -4633,6 +4633,7 @@ dependencies = [
  "futures 0.3.25",
  "fuzzy",
  "git",
+ "glob",
  "gpui",
  "ignore",
  "language",

crates/project/Cargo.toml 🔗

@@ -27,6 +27,7 @@ fs = { path = "../fs" }
 fsevent = { path = "../fsevent" }
 fuzzy = { path = "../fuzzy" }
 git = { path = "../git" }
+glob = { version = "0.3.1" }
 gpui = { path = "../gpui" }
 language = { path = "../language" }
 lsp = { path = "../lsp" }

crates/project/src/lsp_glob_set.rs 🔗

@@ -0,0 +1,121 @@
+use anyhow::{anyhow, Result};
+use std::path::Path;
+
+#[derive(Default)]
+pub struct LspGlobSet {
+    patterns: Vec<glob::Pattern>,
+}
+
+impl LspGlobSet {
+    pub fn clear(&mut self) {
+        self.patterns.clear();
+    }
+
+    /// Add a pattern to the glob set.
+    ///
+    /// LSP's glob syntax supports bash-style brace expansion. For example,
+    /// the pattern '*.{js,ts}' would match all JavaScript or TypeScript files.
+    /// This is not a part of the standard libc glob syntax, and isn't supported
+    /// by the `glob` crate. So we pre-process the glob patterns, producing a
+    /// separate glob `Pattern` object for each part of a brace expansion.
+    pub fn add_pattern(&mut self, pattern: &str) -> Result<()> {
+        // Find all of the ranges of `pattern` that contain matched curly braces.
+        let mut expansion_ranges = Vec::new();
+        let mut expansion_start_ix = None;
+        for (ix, c) in pattern.match_indices(|c| ['{', '}'].contains(&c)) {
+            match c {
+                "{" => {
+                    if expansion_start_ix.is_some() {
+                        return Err(anyhow!("nested braces in glob patterns aren't supported"));
+                    }
+                    expansion_start_ix = Some(ix);
+                }
+                "}" => {
+                    if let Some(start_ix) = expansion_start_ix {
+                        expansion_ranges.push(start_ix..ix + 1);
+                    }
+                    expansion_start_ix = None;
+                }
+                _ => {}
+            }
+        }
+
+        // Starting with a single pattern, process each brace expansion by cloning
+        // the pattern once per element of the expansion.
+        let mut unexpanded_patterns = vec![];
+        let mut expanded_patterns = vec![pattern.to_string()];
+
+        for outer_range in expansion_ranges.into_iter().rev() {
+            let inner_range = (outer_range.start + 1)..(outer_range.end - 1);
+            std::mem::swap(&mut unexpanded_patterns, &mut expanded_patterns);
+            for unexpanded_pattern in unexpanded_patterns.drain(..) {
+                for part in unexpanded_pattern[inner_range.clone()].split(',') {
+                    let mut expanded_pattern = unexpanded_pattern.clone();
+                    expanded_pattern.replace_range(outer_range.clone(), part);
+                    expanded_patterns.push(expanded_pattern);
+                }
+            }
+        }
+
+        // Parse the final glob patterns and add them to the set.
+        for pattern in expanded_patterns {
+            let pattern = glob::Pattern::new(&pattern)?;
+            self.patterns.push(pattern);
+        }
+
+        Ok(())
+    }
+
+    pub fn matches(&self, path: &Path) -> bool {
+        self.patterns
+            .iter()
+            .any(|pattern| pattern.matches_path(path))
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn test_glob_set() {
+        let mut watch = LspGlobSet::default();
+        watch.add_pattern("/a/**/*.rs").unwrap();
+        watch.add_pattern("/a/**/Cargo.toml").unwrap();
+
+        assert!(watch.matches("/a/b.rs".as_ref()));
+        assert!(watch.matches("/a/b/c.rs".as_ref()));
+
+        assert!(!watch.matches("/b/c.rs".as_ref()));
+        assert!(!watch.matches("/a/b.ts".as_ref()));
+    }
+
+    #[test]
+    fn test_brace_expansion() {
+        let mut watch = LspGlobSet::default();
+        watch.add_pattern("/a/*.{ts,js,tsx}").unwrap();
+
+        assert!(watch.matches("/a/one.js".as_ref()));
+        assert!(watch.matches("/a/two.ts".as_ref()));
+        assert!(watch.matches("/a/three.tsx".as_ref()));
+
+        assert!(!watch.matches("/a/one.j".as_ref()));
+        assert!(!watch.matches("/a/two.s".as_ref()));
+        assert!(!watch.matches("/a/three.t".as_ref()));
+        assert!(!watch.matches("/a/four.t".as_ref()));
+        assert!(!watch.matches("/a/five.xt".as_ref()));
+    }
+
+    #[test]
+    fn test_multiple_brace_expansion() {
+        let mut watch = LspGlobSet::default();
+        watch.add_pattern("/a/{one,two,three}.{b*c,d*e}").unwrap();
+
+        assert!(watch.matches("/a/one.bic".as_ref()));
+        assert!(watch.matches("/a/two.dole".as_ref()));
+        assert!(watch.matches("/a/three.deeee".as_ref()));
+
+        assert!(!watch.matches("/a/four.bic".as_ref()));
+        assert!(!watch.matches("/a/one.be".as_ref()));
+    }
+}

crates/project/src/project.rs 🔗

@@ -1,5 +1,6 @@
 mod ignore;
 mod lsp_command;
+mod lsp_glob_set;
 pub mod search;
 pub mod terminals;
 pub mod worktree;
@@ -33,10 +34,11 @@ use language::{
     Transaction, Unclipped,
 };
 use lsp::{
-    DiagnosticSeverity, DiagnosticTag, DocumentHighlightKind, LanguageServer, LanguageString,
-    MarkedString,
+    DiagnosticSeverity, DiagnosticTag, DidChangeWatchedFilesRegistrationOptions,
+    DocumentHighlightKind, LanguageServer, LanguageString, MarkedString,
 };
 use lsp_command::*;
+use lsp_glob_set::LspGlobSet;
 use postage::watch;
 use rand::prelude::*;
 use search::SearchQuery;
@@ -188,6 +190,7 @@ pub enum LanguageServerState {
         language: Arc<Language>,
         adapter: Arc<CachedLspAdapter>,
         server: Arc<LanguageServer>,
+        watched_paths: LspGlobSet,
         simulate_disk_based_diagnostics_completion: Option<Task<()>>,
     },
 }
@@ -2046,8 +2049,26 @@ impl Project {
                             })
                             .detach();
                         language_server
-                            .on_request::<lsp::request::RegisterCapability, _, _>(|_, _| async {
-                                Ok(())
+                            .on_request::<lsp::request::RegisterCapability, _, _>({
+                                let this = this.downgrade();
+                                move |params, mut cx| async move {
+                                    let this = this
+                                        .upgrade(&cx)
+                                        .ok_or_else(|| anyhow!("project dropped"))?;
+                                    for reg in params.registrations {
+                                        if reg.method == "workspace/didChangeWatchedFiles" {
+                                            if let Some(options) = reg.register_options {
+                                                let options = serde_json::from_value(options)?;
+                                                this.update(&mut cx, |this, cx| {
+                                                    this.on_lsp_did_change_watched_files(
+                                                        server_id, options, cx,
+                                                    );
+                                                });
+                                            }
+                                        }
+                                    }
+                                    Ok(())
+                                }
                             })
                             .detach();
 
@@ -2117,6 +2138,7 @@ impl Project {
                                 LanguageServerState::Running {
                                     adapter: adapter.clone(),
                                     language,
+                                    watched_paths: Default::default(),
                                     server: language_server.clone(),
                                     simulate_disk_based_diagnostics_completion: None,
                                 },
@@ -2509,6 +2531,23 @@ impl Project {
         }
     }
 
+    fn on_lsp_did_change_watched_files(
+        &mut self,
+        language_server_id: usize,
+        params: DidChangeWatchedFilesRegistrationOptions,
+        cx: &mut ModelContext<Self>,
+    ) {
+        if let Some(LanguageServerState::Running { watched_paths, .. }) =
+            self.language_servers.get_mut(&language_server_id)
+        {
+            watched_paths.clear();
+            for watcher in params.watchers {
+                watched_paths.add_pattern(&watcher.glob_pattern).log_err();
+            }
+            cx.notify();
+        }
+    }
+
     async fn on_lsp_workspace_edit(
         this: WeakModelHandle<Self>,
         params: lsp::ApplyWorkspaceEditParams,
@@ -4592,15 +4631,20 @@ impl Project {
         for ((server_worktree_id, _), server_id) in &self.language_server_ids {
             if *server_worktree_id == worktree_id {
                 if let Some(server) = self.language_servers.get(server_id) {
-                    if let LanguageServerState::Running { server, .. } = server {
-                        server
-                            .notify::<lsp::notification::DidChangeWatchedFiles>(
-                                lsp::DidChangeWatchedFilesParams {
-                                    changes: changes
-                                        .iter()
-                                        .map(|(path, change)| lsp::FileEvent {
-                                            uri: lsp::Url::from_file_path(abs_path.join(path))
-                                                .unwrap(),
+                    if let LanguageServerState::Running {
+                        server,
+                        watched_paths,
+                        ..
+                    } = server
+                    {
+                        let params = lsp::DidChangeWatchedFilesParams {
+                            changes: changes
+                                .iter()
+                                .filter_map(|(path, change)| {
+                                    let path = abs_path.join(path);
+                                    if watched_paths.matches(&path) {
+                                        Some(lsp::FileEvent {
+                                            uri: lsp::Url::from_file_path(path).unwrap(),
                                             typ: match change {
                                                 PathChange::Added => lsp::FileChangeType::CREATED,
                                                 PathChange::Removed => lsp::FileChangeType::DELETED,
@@ -4610,10 +4654,18 @@ impl Project {
                                                 }
                                             },
                                         })
-                                        .collect(),
-                                },
-                            )
-                            .log_err();
+                                    } else {
+                                        None
+                                    }
+                                })
+                                .collect(),
+                        };
+
+                        if !params.changes.is_empty() {
+                            server
+                                .notify::<lsp::notification::DidChangeWatchedFiles>(params)
+                                .log_err();
+                        }
                     }
                 }
             }

crates/project/src/project_tests.rs 🔗

@@ -493,6 +493,24 @@ async fn test_reporting_fs_changes_to_language_servers(cx: &mut gpui::TestAppCon
     // Keep track of the FS events reported to the language server.
     let fake_server = fake_servers.next().await.unwrap();
     let file_changes = Arc::new(Mutex::new(Vec::new()));
+    fake_server
+        .request::<lsp::request::RegisterCapability>(lsp::RegistrationParams {
+            registrations: vec![lsp::Registration {
+                id: Default::default(),
+                method: "workspace/didChangeWatchedFiles".to_string(),
+                register_options: serde_json::to_value(
+                    lsp::DidChangeWatchedFilesRegistrationOptions {
+                        watchers: vec![lsp::FileSystemWatcher {
+                            glob_pattern: "*.{rs,c}".to_string(),
+                            kind: None,
+                        }],
+                    },
+                )
+                .ok(),
+            }],
+        })
+        .await
+        .unwrap();
     fake_server.handle_notification::<lsp::notification::DidChangeWatchedFiles, _>({
         let file_changes = file_changes.clone();
         move |params, _| {
@@ -505,15 +523,19 @@ async fn test_reporting_fs_changes_to_language_servers(cx: &mut gpui::TestAppCon
     cx.foreground().run_until_parked();
     assert_eq!(file_changes.lock().len(), 0);
 
-    // Perform some file system mutations.
+    // Perform some file system mutations, two of which match the watched patterns,
+    // and one of which does not.
     fs.create_file("/the-root/c.rs".as_ref(), Default::default())
         .await
         .unwrap();
+    fs.create_file("/the-root/d.txt".as_ref(), Default::default())
+        .await
+        .unwrap();
     fs.remove_file("/the-root/b.rs".as_ref(), Default::default())
         .await
         .unwrap();
 
-    // The language server receives events for both FS mutations.
+    // The language server receives events for the FS mutations that match its watch patterns.
     cx.foreground().run_until_parked();
     assert_eq!(
         &*file_changes.lock(),