use arc_swap::{access::Map, ArcSwap}; use futures_util::Stream; use helix_core::{ chars::char_is_word, diagnostic::{DiagnosticTag, NumberOrString}, path::get_relative_path, pos_at_coords, syntax, Selection, }; use helix_lsp::{ lsp::{self, notification::Notification}, util::lsp_pos_to_pos, LspProgressMap, }; use helix_view::{ align_view, document::DocumentSavedEventResult, editor::{ConfigEvent, EditorEvent}, graphics::Rect, theme, tree::Layout, Align, Editor, }; use serde_json::json; use tui::backend::Backend; use crate::{ args::Args, commands::apply_workspace_edit, compositor::{Compositor, Event}, config::Config, job::Jobs, keymap::Keymaps, ui::{self, overlay::overlaid}, }; use log::{debug, error, warn}; #[cfg(not(feature = "integration"))] use std::io::stdout; use std::{collections::btree_map::Entry, io::stdin, path::Path, sync::Arc}; use anyhow::{Context, Error}; use crossterm::{event::Event as CrosstermEvent, tty::IsTty}; #[cfg(not(windows))] use {signal_hook::consts::signal, signal_hook_tokio::Signals}; #[cfg(windows)] type Signals = futures_util::stream::Empty<()>; #[cfg(not(feature = "integration"))] use tui::backend::CrosstermBackend; #[cfg(feature = "integration")] use tui::backend::TestBackend; #[cfg(not(feature = "integration"))] type TerminalBackend = CrosstermBackend; #[cfg(feature = "integration")] type TerminalBackend = TestBackend; type Terminal = tui::terminal::Terminal; pub struct Application { compositor: Compositor, terminal: Terminal, pub editor: Editor, config: Arc>, #[allow(dead_code)] theme_loader: Arc, #[allow(dead_code)] syn_loader: Arc, signals: Signals, jobs: Jobs, lsp_progress: LspProgressMap, } #[cfg(feature = "integration")] fn setup_integration_logging() { let level = std::env::var("HELIX_LOG_LEVEL") .map(|lvl| lvl.parse().unwrap()) .unwrap_or(log::LevelFilter::Info); // Separate file config so we can include year, month and day in file logs let _ = fern::Dispatch::new() .format(|out, message, record| { out.finish(format_args!( "{} {} [{}] {}", chrono::Local::now().format("%Y-%m-%dT%H:%M:%S%.3f"), record.target(), record.level(), message )) }) .level(level) .chain(std::io::stdout()) .apply(); } impl Application { pub fn new( args: Args, config: Config, syn_loader_conf: syntax::Configuration, ) -> Result { #[cfg(feature = "integration")] setup_integration_logging(); use helix_view::editor::Action; let mut theme_parent_dirs = vec![helix_loader::config_dir()]; theme_parent_dirs.extend(helix_loader::runtime_dirs().iter().cloned()); let theme_loader = std::sync::Arc::new(theme::Loader::new(&theme_parent_dirs)); let true_color = config.editor.true_color || crate::true_color(); let theme = config .theme .as_ref() .and_then(|theme| { theme_loader .load(theme) .map_err(|e| { log::warn!("failed to load theme `{}` - {}", theme, e); e }) .ok() .filter(|theme| (true_color || theme.is_16_color())) }) .unwrap_or_else(|| theme_loader.default_theme(true_color)); let syn_loader = std::sync::Arc::new(syntax::Loader::new(syn_loader_conf)); #[cfg(not(feature = "integration"))] let backend = CrosstermBackend::new(stdout(), &config.editor); #[cfg(feature = "integration")] let backend = TestBackend::new(120, 150); let terminal = Terminal::new(backend)?; let area = terminal.size().expect("couldn't get terminal size"); let mut compositor = Compositor::new(area); let config = Arc::new(ArcSwap::from_pointee(config)); let mut editor = Editor::new( area, theme_loader.clone(), syn_loader.clone(), Arc::new(Map::new(Arc::clone(&config), |config: &Config| { &config.editor })), ); let keys = Box::new(Map::new(Arc::clone(&config), |config: &Config| { &config.keys })); let editor_view = Box::new(ui::EditorView::new(Keymaps::new(keys))); compositor.push(editor_view); if args.load_tutor { let path = helix_loader::runtime_file(Path::new("tutor")); editor.open(&path, Action::VerticalSplit)?; // Unset path to prevent accidentally saving to the original tutor file. doc_mut!(editor).set_path(None); } else if !args.files.is_empty() { let mut files_it = args.files.into_iter().peekable(); // If the first file is a directory, skip it and open a picker if let Some((first, _)) = files_it.next_if(|(p, _)| p.is_dir()) { let picker = ui::file_picker(first, &config.load().editor); compositor.push(Box::new(overlaid(picker))); } // If there are any more files specified, open them if files_it.peek().is_some() { let mut nr_of_files = 0; for (file, pos) in files_it { nr_of_files += 1; if file.is_dir() { return Err(anyhow::anyhow!( "expected a path to file, found a directory. (to open a directory pass it as first argument)" )); } else { // If the user passes in either `--vsplit` or // `--hsplit` as a command line argument, all the given // files will be opened according to the selected // option. If neither of those two arguments are passed // in, just load the files normally. let action = match args.split { _ if nr_of_files == 1 => Action::VerticalSplit, Some(Layout::Vertical) => Action::VerticalSplit, Some(Layout::Horizontal) => Action::HorizontalSplit, None => Action::Load, }; let doc_id = editor .open(&file, action) .context(format!("open '{}'", file.to_string_lossy()))?; // with Action::Load all documents have the same view // NOTE: this isn't necessarily true anymore. If // `--vsplit` or `--hsplit` are used, the file which is // opened last is focused on. let view_id = editor.tree.focus; let doc = doc_mut!(editor, &doc_id); let pos = Selection::point(pos_at_coords(doc.text().slice(..), pos, true)); doc.set_selection(view_id, pos); } } editor.set_status(format!( "Loaded {} file{}.", nr_of_files, if nr_of_files == 1 { "" } else { "s" } // avoid "Loaded 1 files." grammo )); // align the view to center after all files are loaded, // does not affect views without pos since it is at the top let (view, doc) = current!(editor); align_view(doc, view, Align::Center); } else { editor.new_file(Action::VerticalSplit); } } else if stdin().is_tty() || cfg!(feature = "integration") { editor.new_file(Action::VerticalSplit); } else { editor .new_file_from_stdin(Action::VerticalSplit) .unwrap_or_else(|_| editor.new_file(Action::VerticalSplit)); } editor.set_theme(theme); #[cfg(windows)] let signals = futures_util::stream::empty(); #[cfg(not(windows))] let signals = Signals::new([ signal::SIGTSTP, signal::SIGCONT, signal::SIGUSR1, signal::SIGTERM, signal::SIGINT, ]) .context("build signal handler")?; let app = Self { compositor, terminal, editor, config, theme_loader, syn_loader, signals, jobs: Jobs::new(), lsp_progress: LspProgressMap::new(), }; Ok(app) } async fn render(&mut self) { if self.compositor.full_redraw { self.terminal.clear().expect("Cannot clear the terminal"); self.compositor.full_redraw = false; } let mut cx = crate::compositor::Context { editor: &mut self.editor, jobs: &mut self.jobs, scroll: None, }; helix_event::start_frame(); cx.editor.needs_redraw = false; let area = self .terminal .autoresize() .expect("Unable to determine terminal size"); // TODO: need to recalculate view tree if necessary let surface = self.terminal.current_buffer_mut(); self.compositor.render(area, surface, &mut cx); let (pos, kind) = self.compositor.cursor(area, &self.editor); // reset cursor cache self.editor.cursor_cache.set(None); let pos = pos.map(|pos| (pos.col as u16, pos.row as u16)); self.terminal.draw(pos, kind).unwrap(); } pub async fn event_loop(&mut self, input_stream: &mut S) where S: Stream> + Unpin, { self.render().await; loop { if !self.event_loop_until_idle(input_stream).await { break; } } } pub async fn event_loop_until_idle(&mut self, input_stream: &mut S) -> bool where S: Stream> + Unpin, { loop { if self.editor.should_close() { return false; } use futures_util::StreamExt; tokio::select! { biased; Some(signal) = self.signals.next() => { if !self.handle_signals(signal).await { return false; }; } Some(event) = input_stream.next() => { self.handle_terminal_events(event).await; } Some(callback) = self.jobs.futures.next() => { self.jobs.handle_callback(&mut self.editor, &mut self.compositor, callback); self.render().await; } Some(callback) = self.jobs.wait_futures.next() => { self.jobs.handle_callback(&mut self.editor, &mut self.compositor, callback); self.render().await; } event = self.editor.wait_event() => { let _idle_handled = self.handle_editor_event(event).await; #[cfg(feature = "integration")] { if _idle_handled { return true; } } } } // for integration tests only, reset the idle timer after every // event to signal when test events are done processing #[cfg(feature = "integration")] { self.editor.reset_idle_timer(); } } } pub fn handle_config_events(&mut self, config_event: ConfigEvent) { match config_event { ConfigEvent::Refresh => self.refresh_config(), // Since only the Application can make changes to Editor's config, // the Editor must send up a new copy of a modified config so that // the Application can apply it. ConfigEvent::Update(editor_config) => { let mut app_config = (*self.config.load().clone()).clone(); app_config.editor = *editor_config; if let Err(err) = self.terminal.reconfigure(app_config.editor.clone().into()) { self.editor.set_error(err.to_string()); }; self.config.store(Arc::new(app_config)); } } // Update all the relevant members in the editor after updating // the configuration. self.editor.refresh_config(); // reset view position in case softwrap was enabled/disabled let scrolloff = self.editor.config().scrolloff; for (view, _) in self.editor.tree.views_mut() { let doc = &self.editor.documents[&view.doc]; view.ensure_cursor_in_view(doc, scrolloff) } } /// refresh language config after config change fn refresh_language_config(&mut self) -> Result<(), Error> { let syntax_config = helix_core::config::user_syntax_loader() .map_err(|err| anyhow::anyhow!("Failed to load language config: {}", err))?; self.syn_loader = std::sync::Arc::new(syntax::Loader::new(syntax_config)); self.editor.syn_loader = self.syn_loader.clone(); for document in self.editor.documents.values_mut() { document.detect_language(self.syn_loader.clone()); } Ok(()) } /// Refresh theme after config change fn refresh_theme(&mut self, config: &Config) -> Result<(), Error> { let true_color = config.editor.true_color || crate::true_color(); let theme = config .theme .as_ref() .and_then(|theme| { self.theme_loader .load(theme) .map_err(|e| { log::warn!("failed to load theme `{}` - {}", theme, e); e }) .ok() .filter(|theme| (true_color || theme.is_16_color())) }) .unwrap_or_else(|| self.theme_loader.default_theme(true_color)); self.editor.set_theme(theme); Ok(()) } fn refresh_config(&mut self) { let mut refresh_config = || -> Result<(), Error> { let default_config = Config::load_default() .map_err(|err| anyhow::anyhow!("Failed to load config: {}", err))?; self.refresh_language_config()?; self.refresh_theme(&default_config)?; self.terminal .reconfigure(default_config.editor.clone().into())?; // Store new config self.config.store(Arc::new(default_config)); Ok(()) }; match refresh_config() { Ok(_) => { self.editor.set_status("Config refreshed"); } Err(err) => { self.editor.set_error(err.to_string()); } } } #[cfg(windows)] // no signal handling available on windows pub async fn handle_signals(&mut self, _signal: ()) -> bool { true } #[cfg(not(windows))] pub async fn handle_signals(&mut self, signal: i32) -> bool { match signal { signal::SIGTSTP => { self.restore_term().unwrap(); // SAFETY: // // - helix must have permissions to send signals to all processes in its signal // group, either by already having the requisite permission, or by having the // user's UID / EUID / SUID match that of the receiving process(es). let res = unsafe { // A pid of 0 sends the signal to the entire process group, allowing the user to // regain control of their terminal if the editor was spawned under another process // (e.g. when running `git commit`). // // We have to send SIGSTOP (not SIGTSTP) to the entire process group, because, // as mentioned above, the terminal will get stuck if `helix` was spawned from // an external process and that process waits for `helix` to complete. This may // be an issue with signal-hook-tokio, but the author of signal-hook believes it // could be a tokio issue instead: // https://github.com/vorner/signal-hook/issues/132 libc::kill(0, signal::SIGSTOP) }; if res != 0 { let err = std::io::Error::last_os_error(); eprintln!("{}", err); let res = err.raw_os_error().unwrap_or(1); std::process::exit(res); } } signal::SIGCONT => { // Copy/Paste from same issue from neovim: // https://github.com/neovim/neovim/issues/12322 // https://github.com/neovim/neovim/pull/13084 for retries in 1..=10 { match self.claim_term().await { Ok(()) => break, Err(err) if retries == 10 => panic!("Failed to claim terminal: {}", err), Err(_) => continue, } } // redraw the terminal let area = self.terminal.size().expect("couldn't get terminal size"); self.compositor.resize(area); self.terminal.clear().expect("couldn't clear terminal"); self.render().await; } signal::SIGUSR1 => { self.refresh_config(); self.render().await; } signal::SIGTERM | signal::SIGINT => { self.restore_term().unwrap(); return false; } _ => unreachable!(), } true } pub async fn handle_idle_timeout(&mut self) { let mut cx = crate::compositor::Context { editor: &mut self.editor, jobs: &mut self.jobs, scroll: None, }; let should_render = self.compositor.handle_event(&Event::IdleTimeout, &mut cx); if should_render || self.editor.needs_redraw { self.render().await; } } pub fn handle_document_write(&mut self, doc_save_event: DocumentSavedEventResult) { let doc_save_event = match doc_save_event { Ok(event) => event, Err(err) => { self.editor.set_error(err.to_string()); return; } }; let doc = match self.editor.document_mut(doc_save_event.doc_id) { None => { warn!( "received document saved event for non-existent doc id: {}", doc_save_event.doc_id ); return; } Some(doc) => doc, }; debug!( "document {:?} saved with revision {}", doc.path(), doc_save_event.revision ); doc.set_last_saved_revision(doc_save_event.revision); let lines = doc_save_event.text.len_lines(); let bytes = doc_save_event.text.len_bytes(); if doc.path() != Some(&doc_save_event.path) { doc.set_path(Some(&doc_save_event.path)); let loader = self.editor.syn_loader.clone(); // borrowing the same doc again to get around the borrow checker let doc = doc_mut!(self.editor, &doc_save_event.doc_id); let id = doc.id(); doc.detect_language(loader); self.editor.refresh_language_servers(id); } // TODO: fix being overwritten by lsp self.editor.set_status(format!( "'{}' written, {}L {}B", get_relative_path(&doc_save_event.path).to_string_lossy(), lines, bytes )); } #[inline(always)] pub async fn handle_editor_event(&mut self, event: EditorEvent) -> bool { log::debug!("received editor event: {:?}", event); match event { EditorEvent::DocumentSaved(event) => { self.handle_document_write(event); self.render().await; } EditorEvent::ConfigEvent(event) => { self.handle_config_events(event); self.render().await; } EditorEvent::LanguageServerMessage((id, call)) => { self.handle_language_server_message(call, id).await; // limit render calls for fast language server messages helix_event::request_redraw(); } EditorEvent::DebuggerEvent(payload) => { let needs_render = self.editor.handle_debugger_message(payload).await; if needs_render { self.render().await; } } EditorEvent::Redraw => { self.render().await; } EditorEvent::IdleTimer => { self.editor.clear_idle_timer(); self.handle_idle_timeout().await; #[cfg(feature = "integration")] { return true; } } } false } pub async fn handle_terminal_events(&mut self, event: std::io::Result) { let mut cx = crate::compositor::Context { editor: &mut self.editor, jobs: &mut self.jobs, scroll: None, }; // Handle key events let should_redraw = match event.unwrap() { CrosstermEvent::Resize(width, height) => { self.terminal .resize(Rect::new(0, 0, width, height)) .expect("Unable to resize terminal"); let area = self.terminal.size().expect("couldn't get terminal size"); self.compositor.resize(area); self.compositor .handle_event(&Event::Resize(width, height), &mut cx) } // Ignore keyboard release events. CrosstermEvent::Key(crossterm::event::KeyEvent { kind: crossterm::event::KeyEventKind::Release, .. }) => false, event => self.compositor.handle_event(&event.into(), &mut cx), }; if should_redraw && !self.editor.should_close() { self.render().await; } } pub async fn handle_language_server_message( &mut self, call: helix_lsp::Call, server_id: usize, ) { use helix_lsp::{Call, MethodCall, Notification}; macro_rules! language_server { () => { match self.editor.language_server_by_id(server_id) { Some(language_server) => language_server, None => { warn!("can't find language server with id `{}`", server_id); return; } } }; } match call { Call::Notification(helix_lsp::jsonrpc::Notification { method, params, .. }) => { let notification = match Notification::parse(&method, params) { Ok(notification) => notification, Err(err) => { log::error!( "received malformed notification from Language Server: {}", err ); return; } }; match notification { Notification::Initialized => { let language_server = language_server!(); // Trigger a workspace/didChangeConfiguration notification after initialization. // This might not be required by the spec but Neovim does this as well, so it's // probably a good idea for compatibility. if let Some(config) = language_server.config() { tokio::spawn(language_server.did_change_configuration(config.clone())); } let docs = self .editor .documents() .filter(|doc| doc.supports_language_server(server_id)); // trigger textDocument/didOpen for docs that are already open for doc in docs { let url = match doc.url() { Some(url) => url, None => continue, // skip documents with no path }; let language_id = doc.language_id().map(ToOwned::to_owned).unwrap_or_default(); tokio::spawn(language_server.text_document_did_open( url, doc.version(), doc.text(), language_id, )); } } Notification::PublishDiagnostics(mut params) => { let path = match params.uri.to_file_path() { Ok(path) => path, Err(_) => { log::error!("Unsupported file URI: {}", params.uri); return; } }; let language_server = language_server!(); if !language_server.is_initialized() { log::error!("Discarding publishDiagnostic notification sent by an uninitialized server: {}", language_server.name()); return; } let offset_encoding = language_server.offset_encoding(); // have to inline the function because of borrow checking... let doc = self.editor.documents.values_mut() .find(|doc| doc.path().map(|p| p == &path).unwrap_or(false)) .filter(|doc| { if let Some(version) = params.version { if version != doc.version() { log::info!("Version ({version}) is out of date for {path:?} (expected ({}), dropping PublishDiagnostic notification", doc.version()); return false; } } true }); if let Some(doc) = doc { let lang_conf = doc.language.clone(); let text = doc.text().clone(); let mut unchaged_diag_sources_ = Vec::new(); if let Some(lang_conf) = &lang_conf { if let Some(old_diagnostics) = self.editor.diagnostics.get(¶ms.uri) { if !lang_conf.persistent_diagnostic_sources.is_empty() { // Sort diagnostics first by severity and then by line numbers. // Note: The `lsp::DiagnosticSeverity` enum is already defined in decreasing order params .diagnostics .sort_unstable_by_key(|d| (d.severity, d.range.start)); } for source in &lang_conf.persistent_diagnostic_sources { let new_diagnostics = params .diagnostics .iter() .filter(|d| d.source.as_ref() == Some(source)); let old_diagnostics = old_diagnostics .iter() .filter(|(d, d_server)| { *d_server == server_id && d.source.as_ref() == Some(source) }) .map(|(d, _)| d); if new_diagnostics.eq(old_diagnostics) { unchaged_diag_sources_.push(source.clone()) } } } } let unchaged_diag_sources = &unchaged_diag_sources_; let diagnostics = params.diagnostics.iter().filter_map(move |diagnostic| { use helix_core::diagnostic::{Diagnostic, Range, Severity::*}; use lsp::DiagnosticSeverity; if diagnostic.source.as_ref().map_or(false, |source| { unchaged_diag_sources.contains(source) }) { return None; } // TODO: convert inside server let start = if let Some(start) = lsp_pos_to_pos( &text, diagnostic.range.start, offset_encoding, ) { start } else { log::warn!("lsp position out of bounds - {:?}", diagnostic); return None; }; let end = if let Some(end) = lsp_pos_to_pos(&text, diagnostic.range.end, offset_encoding) { end } else { log::warn!("lsp position out of bounds - {:?}", diagnostic); return None; }; let severity = diagnostic.severity.map(|severity| match severity { DiagnosticSeverity::ERROR => Error, DiagnosticSeverity::WARNING => Warning, DiagnosticSeverity::INFORMATION => Info, DiagnosticSeverity::HINT => Hint, severity => unreachable!( "unrecognized diagnostic severity: {:?}", severity ), }); if let Some(lang_conf) = &lang_conf { if let Some(severity) = severity { if severity < lang_conf.diagnostic_severity { return None; } } }; let code = match diagnostic.code.clone() { Some(x) => match x { lsp::NumberOrString::Number(x) => { Some(NumberOrString::Number(x)) } lsp::NumberOrString::String(x) => { Some(NumberOrString::String(x)) } }, None => None, }; let tags = if let Some(tags) = &diagnostic.tags { let new_tags = tags .iter() .filter_map(|tag| match *tag { lsp::DiagnosticTag::DEPRECATED => { Some(DiagnosticTag::Deprecated) } lsp::DiagnosticTag::UNNECESSARY => { Some(DiagnosticTag::Unnecessary) } _ => None, }) .collect(); new_tags } else { Vec::new() }; let ends_at_word = start != end && end != 0 && text.get_char(end - 1).map_or(false, char_is_word); let starts_at_word = start != end && text.get_char(start).map_or(false, char_is_word); Some(Diagnostic { range: Range { start, end }, ends_at_word, starts_at_word, zero_width: start == end, line: diagnostic.range.start.line as usize, message: diagnostic.message.clone(), severity, code, tags, source: diagnostic.source.clone(), data: diagnostic.data.clone(), language_server_id: server_id, }) }); doc.replace_diagnostics(diagnostics, unchaged_diag_sources, server_id); } let diagnostics = params.diagnostics.into_iter().map(|d| (d, server_id)); // Insert the original lsp::Diagnostics here because we may have no open document // for diagnosic message and so we can't calculate the exact position. // When using them later in the diagnostics picker, we calculate them on-demand. let diagnostics = match self.editor.diagnostics.entry(params.uri) { Entry::Occupied(o) => { let current_diagnostics = o.into_mut(); // there may entries of other language servers, which is why we can't overwrite the whole entry current_diagnostics.retain(|(_, lsp_id)| *lsp_id != server_id); current_diagnostics.extend(diagnostics); current_diagnostics // Sort diagnostics first by severity and then by line numbers. } Entry::Vacant(v) => v.insert(diagnostics.collect()), }; // Sort diagnostics first by severity and then by line numbers. // Note: The `lsp::DiagnosticSeverity` enum is already defined in decreasing order diagnostics.sort_unstable_by_key(|(d, server_id)| { (d.severity, d.range.start, *server_id) }); } Notification::ShowMessage(params) => { log::warn!("unhandled window/showMessage: {:?}", params); } Notification::LogMessage(params) => { log::info!("window/logMessage: {:?}", params); } Notification::ProgressMessage(params) if !self .compositor .has_component(std::any::type_name::()) => { let editor_view = self .compositor .find::() .expect("expected at least one EditorView"); let lsp::ProgressParams { token, value } = params; let lsp::ProgressParamsValue::WorkDone(work) = value; let parts = match &work { lsp::WorkDoneProgress::Begin(lsp::WorkDoneProgressBegin { title, message, percentage, .. }) => (Some(title), message, percentage), lsp::WorkDoneProgress::Report(lsp::WorkDoneProgressReport { message, percentage, .. }) => (None, message, percentage), lsp::WorkDoneProgress::End(lsp::WorkDoneProgressEnd { message }) => { if message.is_some() { (None, message, &None) } else { self.lsp_progress.end_progress(server_id, &token); if !self.lsp_progress.is_progressing(server_id) { editor_view.spinners_mut().get_or_create(server_id).stop(); } self.editor.clear_status(); // we want to render to clear any leftover spinners or messages return; } } }; let token_d: &dyn std::fmt::Display = match &token { lsp::NumberOrString::Number(n) => n, lsp::NumberOrString::String(s) => s, }; let status = match parts { (Some(title), Some(message), Some(percentage)) => { format!("[{}] {}% {} - {}", token_d, percentage, title, message) } (Some(title), None, Some(percentage)) => { format!("[{}] {}% {}", token_d, percentage, title) } (Some(title), Some(message), None) => { format!("[{}] {} - {}", token_d, title, message) } (None, Some(message), Some(percentage)) => { format!("[{}] {}% {}", token_d, percentage, message) } (Some(title), None, None) => { format!("[{}] {}", token_d, title) } (None, Some(message), None) => { format!("[{}] {}", token_d, message) } (None, None, Some(percentage)) => { format!("[{}] {}%", token_d, percentage) } (None, None, None) => format!("[{}]", token_d), }; if let lsp::WorkDoneProgress::End(_) = work { self.lsp_progress.end_progress(server_id, &token); if !self.lsp_progress.is_progressing(server_id) { editor_view.spinners_mut().get_or_create(server_id).stop(); } } else { self.lsp_progress.update(server_id, token, work); } if self.config.load().editor.lsp.display_messages { self.editor.set_status(status); } } Notification::ProgressMessage(_params) => { // do nothing } Notification::Exit => { self.editor.set_status("Language server exited"); // LSPs may produce diagnostics for files that haven't been opened in helix, // we need to clear those and remove the entries from the list if this leads to // an empty diagnostic list for said files for diags in self.editor.diagnostics.values_mut() { diags.retain(|(_, lsp_id)| *lsp_id != server_id); } self.editor.diagnostics.retain(|_, diags| !diags.is_empty()); // Clear any diagnostics for documents with this server open. for doc in self.editor.documents_mut() { doc.clear_diagnostics(server_id); } // Remove the language server from the registry. self.editor.language_servers.remove_by_id(server_id); } } } Call::MethodCall(helix_lsp::jsonrpc::MethodCall { method, params, id, .. }) => { let reply = match MethodCall::parse(&method, params) { Err(helix_lsp::Error::Unhandled) => { error!( "Language Server: Method {} not found in request {}", method, id ); Err(helix_lsp::jsonrpc::Error { code: helix_lsp::jsonrpc::ErrorCode::MethodNotFound, message: format!("Method not found: {}", method), data: None, }) } Err(err) => { log::error!( "Language Server: Received malformed method call {} in request {}: {}", method, id, err ); Err(helix_lsp::jsonrpc::Error { code: helix_lsp::jsonrpc::ErrorCode::ParseError, message: format!("Malformed method call: {}", method), data: None, }) } Ok(MethodCall::WorkDoneProgressCreate(params)) => { self.lsp_progress.create(server_id, params.token); let editor_view = self .compositor .find::() .expect("expected at least one EditorView"); let spinner = editor_view.spinners_mut().get_or_create(server_id); if spinner.is_stopped() { spinner.start(); } Ok(serde_json::Value::Null) } Ok(MethodCall::ApplyWorkspaceEdit(params)) => { let language_server = language_server!(); if language_server.is_initialized() { let offset_encoding = language_server.offset_encoding(); let res = apply_workspace_edit( &mut self.editor, offset_encoding, ¶ms.edit, ); Ok(json!(lsp::ApplyWorkspaceEditResponse { applied: res.is_ok(), failure_reason: res.as_ref().err().map(|err| err.kind.to_string()), failed_change: res .as_ref() .err() .map(|err| err.failed_change_idx as u32), })) } else { Err(helix_lsp::jsonrpc::Error { code: helix_lsp::jsonrpc::ErrorCode::InvalidRequest, message: "Server must be initialized to request workspace edits" .to_string(), data: None, }) } } Ok(MethodCall::WorkspaceFolders) => { Ok(json!(&*language_server!().workspace_folders().await)) } Ok(MethodCall::WorkspaceConfiguration(params)) => { let language_server = language_server!(); let result: Vec<_> = params .items .iter() .map(|item| { let mut config = language_server.config()?; if let Some(section) = item.section.as_ref() { // for some reason some lsps send an empty string (observed in 'vscode-eslint-language-server') if !section.is_empty() { for part in section.split('.') { config = config.get(part)?; } } } Some(config) }) .collect(); Ok(json!(result)) } 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)); } Call::Invalid { id } => log::error!("LSP invalid method call id={:?}", id), } } async fn claim_term(&mut self) -> std::io::Result<()> { let terminal_config = self.config.load().editor.clone().into(); self.terminal.claim(terminal_config) } fn restore_term(&mut self) -> std::io::Result<()> { let terminal_config = self.config.load().editor.clone().into(); use helix_view::graphics::CursorKind; self.terminal .backend_mut() .show_cursor(CursorKind::Block) .ok(); self.terminal.restore(terminal_config) } pub async fn run(&mut self, input_stream: &mut S) -> Result where S: Stream> + Unpin, { self.claim_term().await?; // Exit the alternate screen and disable raw mode before panicking let hook = std::panic::take_hook(); std::panic::set_hook(Box::new(move |info| { // We can't handle errors properly inside this closure. And it's // probably not a good idea to `unwrap()` inside a panic handler. // So we just ignore the `Result`. let _ = TerminalBackend::force_restore(); hook(info); })); self.event_loop(input_stream).await; let close_errs = self.close().await; self.restore_term()?; for err in close_errs { self.editor.exit_code = 1; eprintln!("Error: {}", err); } Ok(self.editor.exit_code) } pub async fn close(&mut self) -> Vec { // [NOTE] we intentionally do not return early for errors because we // want to try to run as much cleanup as we can, regardless of // errors along the way let mut errs = Vec::new(); if let Err(err) = self .jobs .finish(&mut self.editor, Some(&mut self.compositor)) .await { log::error!("Error executing job: {}", err); errs.push(err); }; if let Err(err) = self.editor.flush_writes().await { log::error!("Error writing: {}", err); errs.push(err); } if self.editor.close_language_servers(None).await.is_err() { log::error!("Timed out waiting for language servers to shutdown"); errs.push(anyhow::format_err!( "Timed out waiting for language servers to shutdown" )); } errs } }