Add support for LSP DidChangeWatchedFiles (#7665)

* Add initial support for LSP DidChangeWatchedFiles

* Move file event Handler to helix-lsp

* Simplify file event handling

* Refactor file event handling

* Block on future within LSP file event handler

* Fully qualify uses of the file_event::Handler type

* Rename ops field to options

* Revert newline removal from helix-view/Cargo.toml

* Ensure file event Handler is cleaned up when lsp client is shutdown
pull/7704/head
Ryan Fowler 11 months ago committed by GitHub
parent 8977123f25
commit 5c41f22c2a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

30
Cargo.lock generated

@ -51,9 +51,9 @@ dependencies = [
[[package]]
name = "aho-corasick"
version = "1.0.1"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67fc08ce920c31afb70f013dcce1bfc3a3195de6a228474e45e1f145b36f8d04"
checksum = "43f6cb1bf222025340178f382c426f13757b2960e89779dfcb319c32542a5a41"
dependencies = [
"memchr",
]
@ -126,13 +126,12 @@ checksum = "630be753d4e58660abd17930c71b647fe46c27ea6b63cc59e1e3851406972e42"
[[package]]
name = "bstr"
version = "1.4.0"
version = "1.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d4260bcc2e8fc9df1eac4919a720effeb63a3f0952f5bf4944adfa18897f09"
checksum = "6798148dccfbff0fae41c7574d2fa8f1ef3492fba0face179de5d8d447d67b05"
dependencies = [
"memchr",
"once_cell",
"regex-automata 0.1.10",
"regex-automata",
"serde",
]
@ -1149,11 +1148,11 @@ dependencies = [
[[package]]
name = "globset"
version = "0.4.10"
version = "0.4.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "029d74589adefde59de1a0c4f4732695c32805624aec7b68d91503d4dba79afc"
checksum = "1391ab1f92ffcc08911957149833e682aa3fe252b9f45f966d2ef972274c97df"
dependencies = [
"aho-corasick 0.7.20",
"aho-corasick 1.0.2",
"bstr",
"fnv",
"log",
@ -1292,6 +1291,7 @@ dependencies = [
"anyhow",
"futures-executor",
"futures-util",
"globset",
"helix-core",
"helix-loader",
"helix-parsec",
@ -1878,25 +1878,19 @@ version = "1.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2eae68fc220f7cf2532e4494aded17545fce192d59cd996e0fe7887f4ceb575"
dependencies = [
"aho-corasick 1.0.1",
"aho-corasick 1.0.2",
"memchr",
"regex-automata 0.3.2",
"regex-automata",
"regex-syntax 0.7.3",
]
[[package]]
name = "regex-automata"
version = "0.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132"
[[package]]
name = "regex-automata"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83d3daa6976cffb758ec878f108ba0e062a45b2d6ca3a2cca965338855476caf"
dependencies = [
"aho-corasick 1.0.1",
"aho-corasick 1.0.2",
"memchr",
"regex-syntax 0.7.3",
]

@ -19,6 +19,7 @@ helix-parsec = { version = "0.6", path = "../helix-parsec" }
anyhow = "1.0"
futures-executor = "0.3"
futures-util = { version = "0.3", features = ["std", "async-await"], default-features = false }
globset = "0.4.11"
log = "0.4"
lsp-types = { version = "0.94" }
serde = { version = "1.0", features = ["derive"] }

@ -544,6 +544,10 @@ impl Client {
normalizes_line_endings: Some(false),
change_annotation_support: None,
}),
did_change_watched_files: Some(lsp::DidChangeWatchedFilesClientCapabilities {
dynamic_registration: Some(true),
relative_pattern_support: Some(false),
}),
..Default::default()
}),
text_document: Some(lsp::TextDocumentClientCapabilities {
@ -1453,4 +1457,13 @@ impl Client {
Some(self.call::<lsp::request::ExecuteCommand>(params))
}
pub fn did_change_watched_files(
&self,
changes: Vec<lsp::FileEvent>,
) -> impl Future<Output = std::result::Result<(), Error>> {
self.notify::<lsp::notification::DidChangeWatchedFiles>(lsp::DidChangeWatchedFilesParams {
changes,
})
}
}

@ -0,0 +1,193 @@
use std::{collections::HashMap, path::PathBuf, sync::Weak};
use globset::{GlobBuilder, GlobSetBuilder};
use tokio::sync::mpsc;
use crate::{lsp, Client};
enum Event {
FileChanged {
path: PathBuf,
},
Register {
client_id: usize,
client: Weak<Client>,
registration_id: String,
options: lsp::DidChangeWatchedFilesRegistrationOptions,
},
Unregister {
client_id: usize,
registration_id: String,
},
RemoveClient {
client_id: usize,
},
}
#[derive(Default)]
struct ClientState {
client: Weak<Client>,
registered: HashMap<String, globset::GlobSet>,
}
/// The Handler uses a dedicated tokio task to respond to file change events by
/// forwarding changes to LSPs that have registered for notifications with a
/// matching glob.
///
/// When an LSP registers for the DidChangeWatchedFiles notification, the
/// Handler is notified by sending the registration details in addition to a
/// weak reference to the LSP client. This is done so that the Handler can have
/// access to the client without preventing the client from being dropped if it
/// is closed and the Handler isn't properly notified.
#[derive(Clone, Debug)]
pub struct Handler {
tx: mpsc::UnboundedSender<Event>,
}
impl Default for Handler {
fn default() -> Self {
Self::new()
}
}
impl Handler {
pub fn new() -> Self {
let (tx, rx) = mpsc::unbounded_channel();
tokio::spawn(Self::run(rx));
Self { tx }
}
pub fn register(
&self,
client_id: usize,
client: Weak<Client>,
registration_id: String,
options: lsp::DidChangeWatchedFilesRegistrationOptions,
) {
let _ = self.tx.send(Event::Register {
client_id,
client,
registration_id,
options,
});
}
pub fn unregister(&self, client_id: usize, registration_id: String) {
let _ = self.tx.send(Event::Unregister {
client_id,
registration_id,
});
}
pub fn file_changed(&self, path: PathBuf) {
let _ = self.tx.send(Event::FileChanged { path });
}
pub fn remove_client(&self, client_id: usize) {
let _ = self.tx.send(Event::RemoveClient { client_id });
}
async fn run(mut rx: mpsc::UnboundedReceiver<Event>) {
let mut state: HashMap<usize, ClientState> = HashMap::new();
while let Some(event) = rx.recv().await {
match event {
Event::FileChanged { path } => {
log::debug!("Received file event for {:?}", &path);
state.retain(|id, client_state| {
if !client_state
.registered
.values()
.any(|glob| glob.is_match(&path))
{
return true;
}
let Some(client) = client_state.client.upgrade() else {
log::warn!("LSP client was dropped: {id}");
return false;
};
let Ok(uri) = lsp::Url::from_file_path(&path) else {
return true;
};
log::debug!(
"Sending didChangeWatchedFiles notification to client '{}'",
client.name()
);
if let Err(err) = crate::block_on(client
.did_change_watched_files(vec![lsp::FileEvent {
uri,
// We currently always send the CHANGED state
// since we don't actually have more context at
// the moment.
typ: lsp::FileChangeType::CHANGED,
}]))
{
log::warn!("Failed to send didChangeWatchedFiles notification to client: {err}");
}
true
});
}
Event::Register {
client_id,
client,
registration_id,
options: ops,
} => {
log::debug!(
"Registering didChangeWatchedFiles for client '{}' with id '{}'",
client_id,
registration_id
);
let mut entry = state.entry(client_id).or_insert_with(ClientState::default);
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);
}
}
}
match builder.build() {
Ok(globset) => {
entry.registered.insert(registration_id, globset);
}
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);
}
log::warn!(
"Unable to build globset for LSP didChangeWatchedFiles {err}"
)
}
}
}
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);
}
}
}
Event::RemoveClient { client_id } => {
log::debug!("Removing LSP client: {client_id}");
state.remove(&client_id);
}
}
}
}
}

@ -1,4 +1,5 @@
mod client;
pub mod file_event;
pub mod jsonrpc;
pub mod snippet;
mod transport;
@ -547,6 +548,7 @@ pub enum MethodCall {
WorkspaceFolders,
WorkspaceConfiguration(lsp::ConfigurationParams),
RegisterCapability(lsp::RegistrationParams),
UnregisterCapability(lsp::UnregistrationParams),
}
impl MethodCall {
@ -570,6 +572,10 @@ impl MethodCall {
let params: lsp::RegistrationParams = params.parse()?;
Self::RegisterCapability(params)
}
lsp::request::UnregisterCapability::METHOD => {
let params: lsp::UnregistrationParams = params.parse()?;
Self::UnregisterCapability(params)
}
_ => {
return Err(Error::Unhandled);
}
@ -629,6 +635,7 @@ pub struct Registry {
syn_loader: Arc<helix_core::syntax::Loader>,
counter: usize,
pub incoming: SelectAll<UnboundedReceiverStream<(usize, Call)>>,
pub file_event_handler: file_event::Handler,
}
impl Registry {
@ -638,6 +645,7 @@ impl Registry {
syn_loader,
counter: 0,
incoming: SelectAll::new(),
file_event_handler: file_event::Handler::new(),
}
}
@ -650,6 +658,7 @@ impl Registry {
}
pub fn remove_by_id(&mut self, id: usize) {
self.file_event_handler.remove_client(id);
self.inner.retain(|_, language_servers| {
language_servers.retain(|ls| id != ls.id());
!language_servers.is_empty()
@ -715,6 +724,7 @@ impl Registry {
.unwrap();
for old_client in old_clients {
self.file_event_handler.remove_client(old_client.id());
tokio::spawn(async move {
let _ = old_client.force_shutdown().await;
});
@ -731,6 +741,7 @@ impl Registry {
pub fn stop(&mut self, name: &str) {
if let Some(clients) = self.inner.remove(name) {
for client in clients {
self.file_event_handler.remove_client(client.id());
tokio::spawn(async move {
let _ = client.force_shutdown().await;
});

@ -5,7 +5,11 @@ use helix_core::{
path::get_relative_path,
pos_at_coords, syntax, Selection,
};
use helix_lsp::{lsp, util::lsp_pos_to_pos, LspProgressMap};
use helix_lsp::{
lsp::{self, notification::Notification},
util::lsp_pos_to_pos,
LspProgressMap,
};
use helix_view::{
align_view,
document::DocumentSavedEventResult,
@ -1080,17 +1084,65 @@ impl Application {
.collect();
Ok(json!(result))
}
Ok(MethodCall::RegisterCapability(_params)) => {
log::warn!("Ignoring a client/registerCapability request because dynamic capability registration is not enabled. Please report this upstream to the language server");
// Language Servers based on the `vscode-languageserver-node` library often send
// client/registerCapability even though we do not enable dynamic registration
// for any capabilities. We should send a MethodNotFound JSONRPC error in this
// case but that rejects the registration promise in the server which causes an
// exit. So we work around this by ignoring the request and sending back an OK
// response.
Ok(MethodCall::RegisterCapability(params)) => {
if let Some(client) = self
.editor
.language_servers
.iter_clients()
.find(|client| client.id() == server_id)
{
for reg in params.registrations {
match reg.method.as_str() {
lsp::notification::DidChangeWatchedFiles::METHOD => {
let Some(options) = reg.register_options else {
continue;
};
let ops: lsp::DidChangeWatchedFilesRegistrationOptions =
match serde_json::from_value(options) {
Ok(ops) => ops,
Err(err) => {
log::warn!("Failed to deserialize DidChangeWatchedFilesRegistrationOptions: {err}");
continue;
}
};
self.editor.language_servers.file_event_handler.register(
client.id(),
Arc::downgrade(client),
reg.id,
ops,
)
}
_ => {
// Language Servers based on the `vscode-languageserver-node` library often send
// client/registerCapability even though we do not enable dynamic registration
// for most capabilities. We should send a MethodNotFound JSONRPC error in this
// case but that rejects the registration promise in the server which causes an
// exit. So we work around this by ignoring the request and sending back an OK
// response.
log::warn!("Ignoring a client/registerCapability request because dynamic capability registration is not enabled. Please report this upstream to the language server");
}
}
}
}
Ok(serde_json::Value::Null)
}
Ok(MethodCall::UnregisterCapability(params)) => {
for unreg in params.unregisterations {
match unreg.method.as_str() {
lsp::notification::DidChangeWatchedFiles::METHOD => {
self.editor
.language_servers
.file_event_handler
.unregister(server_id, unreg.id);
}
_ => {
log::warn!("Received unregistration request for unsupported method: {}", unreg.method);
}
}
}
Ok(serde_json::Value::Null)
}
};
tokio::spawn(language_server!().reply(id, reply));

@ -1283,7 +1283,14 @@ fn reload(
doc.reload(view, &cx.editor.diff_providers, redraw_handle)
.map(|_| {
view.ensure_cursor_in_view(doc, scrolloff);
})
})?;
if let Some(path) = doc.path() {
cx.editor
.language_servers
.file_event_handler
.file_changed(path.clone());
}
Ok(())
}
fn reload_all(
@ -1324,6 +1331,12 @@ fn reload_all(
let redraw_handle = cx.editor.redraw_handle.clone();
doc.reload(view, &cx.editor.diff_providers, redraw_handle)?;
if let Some(path) = doc.path() {
cx.editor
.language_servers
.file_event_handler
.file_changed(path.clone());
}
for view_id in view_ids {
let view = view_mut!(cx.editor, view_id);

@ -1535,7 +1535,18 @@ impl Editor {
let path = path.map(|path| path.into());
let doc = doc_mut!(self, &doc_id);
let future = doc.save(path, force)?;
let doc_save_future = doc.save(path, force)?;
// When a file is written to, notify the file event handler.
// Note: This can be removed once proper file watching is implemented.
let handler = self.language_servers.file_event_handler.clone();
let future = async move {
let res = doc_save_future.await;
if let Ok(event) = &res {
handler.file_changed(event.path.clone());
}
res
};
use futures_util::stream;
@ -1671,6 +1682,14 @@ impl Editor {
&self,
timeout: Option<u64>,
) -> Result<(), tokio::time::error::Elapsed> {
// Remove all language servers from the file event handler.
// Note: this is non-blocking.
for client in self.language_servers.iter_clients() {
self.language_servers
.file_event_handler
.remove_client(client.id());
}
tokio::time::timeout(
Duration::from_millis(timeout.unwrap_or(3000)),
future::join_all(

Loading…
Cancel
Save