Unnamed repository; edit this file 'description' to name the repository.
| -rw-r--r-- | Cargo.lock | 1 | ||||
| -rw-r--r-- | helix-core/src/file_watcher.rs | 7 | ||||
| -rw-r--r-- | helix-lsp/src/client.rs | 2 | ||||
| -rw-r--r-- | helix-lsp/src/file_event.rs | 306 | ||||
| -rw-r--r-- | helix-term/src/application.rs | 15 |
5 files changed, 233 insertions, 98 deletions
@@ -1627,6 +1627,7 @@ dependencies = [ "gix", "helix-core", "helix-event", + "helix-stdx", "imara-diff 0.2.0", "log", "parking_lot", diff --git a/helix-core/src/file_watcher.rs b/helix-core/src/file_watcher.rs index 9e49bab9..f0101368 100644 --- a/helix-core/src/file_watcher.rs +++ b/helix-core/src/file_watcher.rs @@ -51,7 +51,7 @@ impl Default for Config { fn default() -> Self { Config { enable: true, - watch_vcs: false, + watch_vcs: true, require_workspace: true, hidden: true, ignore: true, @@ -167,7 +167,10 @@ impl Watcher { return; } let (workspace, _) = helix_loader::find_workspace(); - self.roots.push((root.clone(), 1)); + if root.starts_with(&workspace) { + return; + } + self.roots.insert(i, (root.clone(), 1)); self.filter = Arc::new(WatchFilter::new( &self.config, &workspace, diff --git a/helix-lsp/src/client.rs b/helix-lsp/src/client.rs index ebc619e2..38a7266a 100644 --- a/helix-lsp/src/client.rs +++ b/helix-lsp/src/client.rs @@ -596,7 +596,7 @@ impl Client { }), did_change_watched_files: Some(lsp::DidChangeWatchedFilesClientCapabilities { dynamic_registration: Some(true), - relative_pattern_support: Some(false), + relative_pattern_support: Some(true), }), file_operations: Some(lsp::WorkspaceFileOperationsClientCapabilities { will_rename: Some(true), diff --git a/helix-lsp/src/file_event.rs b/helix-lsp/src/file_event.rs index ccfe4558..c07bfe80 100644 --- a/helix-lsp/src/file_event.rs +++ b/helix-lsp/src/file_event.rs @@ -1,9 +1,12 @@ -use std::path::Path; +use std::collections::hash_map; +use std::mem::take; +use std::path::{is_separator, Path}; use std::{collections::HashMap, path::PathBuf, sync::Weak}; -use globset::{GlobBuilder, GlobSetBuilder}; +use globset::{Glob, GlobSet}; use helix_core::file_watcher::{EventType, Events, FileSystemDidChange}; use helix_event::register_hook; +use helix_lsp_types::WatchKind; use tokio::sync::mpsc; use crate::{lsp, Client, LanguageServerId}; @@ -15,7 +18,6 @@ enum Event { }, FileWatcher(Events), Register { - client_id: LanguageServerId, client: Weak<Client>, registration_id: String, options: lsp::DidChangeWatchedFilesRegistrationOptions, @@ -29,10 +31,108 @@ enum Event { }, } -#[derive(Default)] struct ClientState { client: Weak<Client>, - registered: HashMap<String, globset::GlobSet>, + registerations: HashMap<String, u32>, + pending: Vec<lsp::FileEvent>, +} + +#[derive(Debug, Clone)] +struct Interest { + glob: Glob, + server: LanguageServerId, + id: u32, + flags: WatchKind, +} + +struct State { + clients: HashMap<LanguageServerId, ClientState>, + glob_matcher: GlobSet, + interest: Vec<Interest>, + // used for matching to avoid reallocation + candidates: Vec<usize>, +} +impl State { + fn notify<'a>(&mut self, events: impl Iterator<Item = (&'a Path, EventType)> + Clone) { + for (path, ty) in events { + let (interest_kind, notification_type) = match ty { + EventType::Create => (WatchKind::Create, lsp::FileChangeType::CREATED), + EventType::Delete => (WatchKind::Delete, lsp::FileChangeType::DELETED), + EventType::Modified => (WatchKind::Change, lsp::FileChangeType::CHANGED), + EventType::Tempfile => continue, + }; + self.glob_matcher.matches_into(path, &mut self.candidates); + for interest in self.candidates.drain(..) { + let interest = &self.interest[interest]; + if !interest.flags.contains(interest_kind) { + continue; + } + let Ok(uri) = lsp::Url::from_file_path(path) else { + continue; + }; + let event = lsp::FileEvent { + uri, + typ: notification_type, + }; + self.clients + .get_mut(&interest.server) + .unwrap() + .pending + .push(event); + } + } + for client_state in self.clients.values_mut() { + if client_state.pending.is_empty() { + continue; + } + let Some(client) = client_state.client.upgrade() else { + continue; + }; + log::debug!( + "Sending didChangeWatchedFiles notification to client '{}'", + client.name() + ); + client.did_change_watched_files(take(&mut client_state.pending)); + } + } + + fn purge_client(&mut self, id: LanguageServerId) { + self.clients.remove(&id); + let interest = self + .interest + .iter() + .filter(|it| it.server != id) + .cloned() + .collect(); + self.rebuild_globmatcher(interest); + } + + fn rebuild_globmatcher(&mut self, interest: Vec<Interest>) { + let mut builder = GlobSet::builder(); + for interest in &interest { + builder.add(interest.glob.clone()); + } + match builder.build() { + Ok(glob_matcher) => { + self.glob_matcher = glob_matcher; + self.interest = interest; + } + Err(err) => { + log::error!("failde to build glob matcher for file watching: ({err})",); + } + } + } +} + +impl Default for State { + fn default() -> State { + State { + clients: Default::default(), + glob_matcher: Default::default(), + interest: Default::default(), + candidates: Vec::with_capacity(32), + } + } } /// The Handler uses a dedicated tokio task to respond to file change events by @@ -69,13 +169,11 @@ impl Handler { pub fn register( &self, - client_id: LanguageServerId, client: Weak<Client>, registration_id: String, options: lsp::DidChangeWatchedFilesRegistrationOptions, ) { let _ = self.tx.send(Event::Register { - client_id, client, registration_id, options, @@ -97,125 +195,145 @@ impl Handler { let _ = self.tx.send(Event::RemoveClient { client_id }); } - fn notify_files<'a>( - state: &mut HashMap<LanguageServerId, ClientState>, - changes: impl Iterator<Item = (&'a Path, lsp::FileChangeType)> + Clone, - ) { - state.retain(|id, client_state| { - let notifications: Vec<_> = changes - .clone() - .filter(|(path, _)| { - client_state - .registered - .values() - .any(|glob| glob.is_match(path)) - }) - .filter_map(|(path, typ)| { - let uri = lsp::Url::from_file_path(path).ok()?; - let event = lsp::FileEvent { uri, typ }; - Some(event) - }) - .collect(); - if notifications.is_empty() { - return false; - } - let Some(client) = client_state.client.upgrade() else { - log::warn!("LSP client was dropped: {id}"); - return false; - }; - log::debug!( - "Sending didChangeWatchedFiles notification to client '{}'", - client.name() - ); - client.did_change_watched_files(notifications); - true - }) - } - async fn run(mut rx: mpsc::UnboundedReceiver<Event>) { - let mut state: HashMap<LanguageServerId, ClientState> = HashMap::new(); + let mut state = State::default(); while let Some(event) = rx.recv().await { match event { Event::FileWatcher(events) => { - Self::notify_files( - &mut state, - events.iter().filter_map(|event| { - let ty = match event.ty { - EventType::Create => lsp::FileChangeType::CREATED, - EventType::Delete => lsp::FileChangeType::DELETED, - EventType::Modified => lsp::FileChangeType::CHANGED, - EventType::Tempfile => return None, - }; - Some((event.path.as_std_path(), ty)) - }), - ); + let events = events + .iter() + .map(|event| (event.path.as_std_path(), event.ty)); + state.notify(events); } Event::FileWritten { path } => { log::debug!("Received file event for {:?}", &path); - Self::notify_files( - &mut state, - [(&*path, lsp::FileChangeType::CHANGED)].iter().cloned(), - ); + state.notify([(&*path, EventType::Modified)].iter().cloned()); } Event::Register { - client_id, client, registration_id, options: ops, } => { + let Some(client_) = client.upgrade() else { + continue; + }; log::debug!( "Registering didChangeWatchedFiles for client '{}' with id '{}'", - client_id, + client_.name(), registration_id ); - let entry = state.entry(client_id).or_default(); + if !state + .clients + .get(&client_.id()) + .is_some_and(|state| !state.client.ptr_eq(&client)) + { + state.purge_client(client_.id()); + } + let entry = state + .clients + .entry(client_.id()) + .or_insert_with(|| ClientState { + client: client.clone(), + registerations: HashMap::with_capacity(8), + pending: Vec::with_capacity(32), + }); entry.client = client; - - let mut builder = GlobSetBuilder::new(); - for watcher in ops.watchers { - if let lsp::GlobPattern::String(pattern) = watcher.glob_pattern { - if let Ok(glob) = GlobBuilder::new(&pattern).build() { - builder.add(glob); - } + let next_id = u32::try_from(entry.registerations.len()).unwrap(); + let (mut interest, id) = match entry.registerations.entry(registration_id) { + hash_map::Entry::Occupied(entry) => { + let id = *entry.get(); + let mut interest = Vec::with_capacity(state.interest.len()); + interest.extend( + state + .interest + .iter() + .filter(|it| it.server != client_.id() || it.id != id) + .cloned(), + ); + (interest, id) } - } - match builder.build() { - Ok(globset) => { - entry.registered.insert(registration_id, globset); + hash_map::Entry::Vacant(entry) => { + entry.insert(next_id); + (state.interest.clone(), next_id) + } + }; + for watcher in ops.watchers { + if watcher.kind.is_some_and(|flags| flags.is_empty()) { + continue; } - Err(err) => { - // Remove any old state for that registration id and - // remove the entire client if it's now empty. - entry.registered.remove(®istration_id); - if entry.registered.is_empty() { - state.remove(&client_id); + let glob = match watcher.glob_pattern { + helix_lsp_types::GlobPattern::String(pattern) => pattern, + helix_lsp_types::GlobPattern::Relative(relative_pattern) => { + let base_url = match relative_pattern.base_uri { + helix_lsp_types::OneOf::Left(folder) => folder.uri, + helix_lsp_types::OneOf::Right(url) => url, + }; + let Ok(mut base_dir) = base_url.to_file_path() else { + log::error!( + "{} provided invalid URL for watching '{base_url}'", + client_.name(), + ); + continue; + }; + if let Ok(dir) = base_dir.canonicalize() { + base_dir = dir + } + let Ok(mut base_dir) = base_dir.into_os_string().into_string() + else { + log::error!( + "{} provided invalid URL for watching '{base_url}' (must be valid utf-8)", + client_.name(), + ); + continue; + }; + if !base_dir.chars().next_back().is_some_and(is_separator) { + base_dir.push('/'); + } + base_dir.push_str(&relative_pattern.pattern); + base_dir + } + }; + match Glob::new(&glob) { + Ok(glob) => { + interest.push(Interest { + glob, + server: client_.id(), + id, + flags: watcher.kind.unwrap_or(WatchKind::all()), + }); + } + Err(err) => { + log::error!( + "{} provided invalid glob for watching '{glob}': ({err})", + client_.name(), + ); } - log::warn!( - "Unable to build globset for LSP didChangeWatchedFiles {err}" - ) } } + state.rebuild_globmatcher(interest); } Event::Unregister { client_id, registration_id, } => { - log::debug!( - "Unregistering didChangeWatchedFiles with id '{}' for client '{}'", - registration_id, - client_id - ); - if let Some(client_state) = state.get_mut(&client_id) { - client_state.registered.remove(®istration_id); - if client_state.registered.is_empty() { - state.remove(&client_id); - } - } + let Some(client_state) = state.clients.get_mut(&client_id) else { + return; + }; + let Some(id) = client_state.registerations.remove(&*registration_id) else { + return; + }; + let interest = state + .interest + .iter() + .filter(|it| it.server != client_id || it.id != id) + .cloned() + .collect(); + state.rebuild_globmatcher(interest); } Event::RemoveClient { client_id } => { log::debug!("Removing LSP client: {client_id}"); - state.remove(&client_id); + state.purge_client(client_id); } } } diff --git a/helix-term/src/application.rs b/helix-term/src/application.rs index 8c1db649..4aabbe7a 100644 --- a/helix-term/src/application.rs +++ b/helix-term/src/application.rs @@ -1058,8 +1058,21 @@ impl Application { continue; } }; + for watch in &ops.watchers { + if let lsp::GlobPattern::Relative(pattern) = + &watch.glob_pattern + { + let base_url = match &pattern.base_uri { + lsp::OneOf::Left(folder) => &folder.uri, + lsp::OneOf::Right(url) => url, + }; + let Ok(base_dir) = base_url.to_file_path() else { + continue; + }; + self.editor.file_watcher.add_root(&base_dir); + } + } self.editor.language_servers.file_event_handler.register( - client.id(), Arc::downgrade(client), reg.id, ops, |