Unnamed repository; edit this file 'description' to name the repository.
support for relative lsp file watcher patterns
Pascal Kuthe 5 months ago
parent 9c81d46 · commit bed958a
-rw-r--r--Cargo.lock1
-rw-r--r--helix-core/src/file_watcher.rs7
-rw-r--r--helix-lsp/src/client.rs2
-rw-r--r--helix-lsp/src/file_event.rs306
-rw-r--r--helix-term/src/application.rs15
5 files changed, 233 insertions, 98 deletions
diff --git a/Cargo.lock b/Cargo.lock
index 2d6165e7..ae7cf739 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -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(&registration_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(&registration_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,