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
This commit is contained in:
Ryan Fowler 2023-07-21 15:21:21 -07:00 committed by GitHub
parent 8977123f25
commit 5c41f22c2a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 325 additions and 29 deletions

30
Cargo.lock generated
View File

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

View File

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

View File

@ -544,6 +544,10 @@ impl Client {
normalizes_line_endings: Some(false), normalizes_line_endings: Some(false),
change_annotation_support: None, change_annotation_support: None,
}), }),
did_change_watched_files: Some(lsp::DidChangeWatchedFilesClientCapabilities {
dynamic_registration: Some(true),
relative_pattern_support: Some(false),
}),
..Default::default() ..Default::default()
}), }),
text_document: Some(lsp::TextDocumentClientCapabilities { text_document: Some(lsp::TextDocumentClientCapabilities {
@ -1453,4 +1457,13 @@ impl Client {
Some(self.call::<lsp::request::ExecuteCommand>(params)) 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,
})
}
} }

193
helix-lsp/src/file_event.rs Normal file
View File

@ -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);
}
}
}
}
}

View File

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

View File

@ -5,7 +5,11 @@ use helix_core::{
path::get_relative_path, path::get_relative_path,
pos_at_coords, syntax, Selection, 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::{ use helix_view::{
align_view, align_view,
document::DocumentSavedEventResult, document::DocumentSavedEventResult,
@ -1080,17 +1084,65 @@ impl Application {
.collect(); .collect();
Ok(json!(result)) Ok(json!(result))
} }
Ok(MethodCall::RegisterCapability(_params)) => { 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"); if let Some(client) = self
// Language Servers based on the `vscode-languageserver-node` library often send .editor
// client/registerCapability even though we do not enable dynamic registration .language_servers
// for any capabilities. We should send a MethodNotFound JSONRPC error in this .iter_clients()
// case but that rejects the registration promise in the server which causes an .find(|client| client.id() == server_id)
// exit. So we work around this by ignoring the request and sending back an OK {
// response. 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(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)); tokio::spawn(language_server!().reply(id, reply));

View File

@ -1283,7 +1283,14 @@ fn reload(
doc.reload(view, &cx.editor.diff_providers, redraw_handle) doc.reload(view, &cx.editor.diff_providers, redraw_handle)
.map(|_| { .map(|_| {
view.ensure_cursor_in_view(doc, scrolloff); 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( fn reload_all(
@ -1324,6 +1331,12 @@ fn reload_all(
let redraw_handle = cx.editor.redraw_handle.clone(); let redraw_handle = cx.editor.redraw_handle.clone();
doc.reload(view, &cx.editor.diff_providers, redraw_handle)?; 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 { for view_id in view_ids {
let view = view_mut!(cx.editor, view_id); let view = view_mut!(cx.editor, view_id);

View File

@ -1535,7 +1535,18 @@ impl Editor {
let path = path.map(|path| path.into()); let path = path.map(|path| path.into());
let doc = doc_mut!(self, &doc_id); 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; use futures_util::stream;
@ -1671,6 +1682,14 @@ impl Editor {
&self, &self,
timeout: Option<u64>, timeout: Option<u64>,
) -> Result<(), tokio::time::error::Elapsed> { ) -> 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( tokio::time::timeout(
Duration::from_millis(timeout.unwrap_or(3000)), Duration::from_millis(timeout.unwrap_or(3000)),
future::join_all( future::join_all(