Unnamed repository; edit this file 'description' to name the repository.
Diffstat (limited to 'helix-term/src/application.rs')
| -rw-r--r-- | helix-term/src/application.rs | 1457 |
1 files changed, 377 insertions, 1080 deletions
diff --git a/helix-term/src/application.rs b/helix-term/src/application.rs index 8c1db649..82ad04d7 100644 --- a/helix-term/src/application.rs +++ b/helix-term/src/application.rs @@ -1,326 +1,175 @@ -use arc_swap::{access::Map, ArcSwap}; -use futures_util::Stream; -use helix_core::{diagnostic::Severity, pos_at_coords, syntax, Range, Selection}; -use helix_lsp::{ - lsp::{self, notification::Notification}, - util::lsp_range_to_range, - LanguageServerId, LspProgressMap, -}; -use helix_stdx::path::get_relative_path; -use helix_view::{ - align_view, - document::{DocumentOpenError, DocumentSavedEventResult}, - editor::{ConfigEvent, EditorEvent}, - graphics::Rect, - theme, - tree::Layout, - Align, Editor, -}; -use serde_json::json; -use tui::backend::Backend; - -use crate::{ - args::Args, - compositor::{Compositor, Event}, - config::Config, - handlers, - job::Jobs, - keymap::Keymaps, - ui::{self, overlay::overlaid}, -}; +use helix_core::{merge_toml_values, syntax}; +use helix_lsp::{lsp, util::lsp_pos_to_pos, LspProgressMap}; +use helix_view::{theme, Editor}; + +use crate::{args::Args, compositor::Compositor, config::Config, job::Jobs, ui}; + +use log::{error, warn}; -use log::{debug, error, info, warn}; use std::{ - io::{stdin, IsTerminal}, - path::Path, + io::{stdout, Write}, sync::Arc, + time::{Duration, Instant}, }; -#[cfg_attr(windows, allow(unused_imports))] -use anyhow::{Context, Error}; +use anyhow::Error; +use crossterm::{ + event::{DisableMouseCapture, EnableMouseCapture, Event, EventStream}, + execute, terminal, +}; #[cfg(not(windows))] -use {signal_hook::consts::signal, signal_hook_tokio::Signals}; +use { + signal_hook::{consts::signal, low_level}, + signal_hook_tokio::Signals, +}; #[cfg(windows)] type Signals = futures_util::stream::Empty<()>; -#[cfg(all(not(windows), not(feature = "integration")))] -use tui::backend::TerminaBackend; - -#[cfg(all(windows, not(feature = "integration")))] -use tui::backend::CrosstermBackend; - -#[cfg(feature = "integration")] -use tui::backend::TestBackend; - -#[cfg(all(not(windows), not(feature = "integration")))] -type TerminalBackend = TerminaBackend; -#[cfg(all(windows, not(feature = "integration")))] -type TerminalBackend = CrosstermBackend<std::io::Stdout>; -#[cfg(feature = "integration")] -type TerminalBackend = TestBackend; - -#[cfg(not(windows))] -type TerminalEvent = termina::Event; -#[cfg(windows)] -type TerminalEvent = crossterm::event::Event; - -type Terminal = tui::terminal::Terminal<TerminalBackend>; - pub struct Application { compositor: Compositor, - terminal: Terminal, - pub editor: Editor, + editor: Editor, - config: Arc<ArcSwap<Config>>, + // TODO should be separate to take only part of the config + config: Config, + + // Currently never read from. Remove the `allow(dead_code)` when + // that changes. + #[allow(dead_code)] + theme_loader: Arc<theme::Loader>, + + // Currently never read from. Remove the `allow(dead_code)` when + // that changes. + #[allow(dead_code)] + syn_loader: Arc<syntax::Loader>, signals: Signals, jobs: Jobs, lsp_progress: LspProgressMap, - - theme_mode: Option<theme::Mode>, -} - -#[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, lang_loader: syntax::Loader) -> Result<Self, Error> { - #[cfg(feature = "integration")] - setup_integration_logging(); - + pub fn new(args: Args, mut config: Config) -> Result<Self, Error> { use helix_view::editor::Action; + let mut compositor = Compositor::new()?; + let size = compositor.size(); + + let conf_dir = helix_core::config_dir(); + + let theme_loader = + std::sync::Arc::new(theme::Loader::new(&conf_dir, &helix_core::runtime_dir())); + + // load default and user config, and merge both + let def_lang_conf: toml::Value = toml::from_slice(include_bytes!("../../languages.toml")) + .expect("Could not parse built-in languages.toml, something must be very wrong"); + let user_lang_conf: Option<toml::Value> = std::fs::read(conf_dir.join("languages.toml")) + .ok() + .map(|raw| toml::from_slice(&raw).expect("Could not parse user languages.toml")); + let lang_conf = match user_lang_conf { + Some(value) => merge_toml_values(def_lang_conf, value), + None => def_lang_conf, + }; + + let theme = if let Some(theme) = &config.theme { + match theme_loader.load(theme) { + Ok(theme) => theme, + Err(e) => { + log::warn!("failed to load theme `{}` - {}", theme, e); + theme_loader.default() + } + } + } else { + theme_loader.default() + }; + + let syn_loader_conf: helix_core::syntax::Configuration = lang_conf + .try_into() + .expect("Could not parse merged (built-in + user) languages.toml"); + let syn_loader = std::sync::Arc::new(syntax::Loader::new(syn_loader_conf)); - let mut theme_parent_dirs = vec![helix_loader::config_dir()]; - theme_parent_dirs.extend(helix_loader::runtime_dirs().iter().cloned()); - let theme_loader = theme::Loader::new(&theme_parent_dirs); - - #[cfg(all(not(windows), not(feature = "integration")))] - let backend = TerminaBackend::new((&config.editor).into()) - .context("failed to create terminal backend")?; - #[cfg(all(windows, not(feature = "integration")))] - let backend = CrosstermBackend::new(std::io::stdout(), (&config.editor).into()); - - #[cfg(feature = "integration")] - let backend = TestBackend::new(120, 150); - - let theme_mode = backend.get_theme_mode(); - let terminal = Terminal::new(backend)?; - let area = terminal.size(); - let mut compositor = Compositor::new(area); - let config = Arc::new(ArcSwap::from_pointee(config)); - let handlers = handlers::setup(config.clone()); let mut editor = Editor::new( - area, - Arc::new(theme_loader), - Arc::new(ArcSwap::from_pointee(lang_loader)), - Arc::new(Map::new(Arc::clone(&config), |config: &Config| { - &config.editor - })), - handlers, - ); - Self::load_configured_theme( - &mut editor, - &config.load(), - terminal.backend().supports_true_color(), - theme_mode, + size, + theme_loader.clone(), + syn_loader.clone(), + config.editor.clone(), ); - let keys = Box::new(Map::new(Arc::clone(&config), |config: &Config| { - &config.keys - })); - let editor_view = Box::new(ui::EditorView::new(Keymaps::new(keys))); + let editor_view = Box::new(ui::EditorView::new(std::mem::take(&mut config.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(&editor, first); - 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 !args.files.is_empty() { + let first = &args.files[0]; // we know it's not empty + if first.is_dir() { + std::env::set_current_dir(&first)?; + editor.new_file(Action::VerticalSplit); + compositor.push(Box::new(ui::file_picker(first.clone()))); + } else { + let nr_of_files = args.files.len(); + editor.open(first.to_path_buf(), Action::VerticalSplit)?; + for file in args.files { if file.is_dir() { return Err(anyhow::anyhow!( - "expected a path to file, but found a directory: {file:?}. (to open a directory pass it as first argument)" + "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 old_id = editor.document_id_by_path(&file); - let doc_id = match editor.open(&file, action) { - // Ignore irregular files during application init. - Err(DocumentOpenError::IrregularFile) => { - nr_of_files -= 1; - continue; - } - Err(err) => return Err(anyhow::anyhow!(err)), - // We can't open more than 1 buffer for 1 file, in this case we already have opened this file previously - Ok(doc_id) if old_id == Some(doc_id) => { - nr_of_files -= 1; - doc_id - } - Ok(doc_id) => doc_id, - }; - // 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 selection = pos - .into_iter() - .map(|coords| { - Range::point(pos_at_coords(doc.text().slice(..), coords, true)) - }) - .collect(); - doc.set_selection(view_id, selection); + editor.open(file.to_path_buf(), Action::Load)?; } } - - // if all files were invalid, replace with empty buffer - if nr_of_files == 0 { - editor.new_file(Action::VerticalSplit); - } else { - 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); + editor.set_status(format!("Loaded {} files.", nr_of_files)); } - } else if stdin().is_terminal() || 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.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 signals = Signals::new(&[signal::SIGTSTP, signal::SIGCONT])?; let app = Self { compositor, - terminal, editor, + config, + + theme_loader, + syn_loader, + signals, jobs: Jobs::new(), lsp_progress: LspProgressMap::new(), - theme_mode, }; 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; - } + fn render(&mut self) { + let editor = &mut self.editor; + let compositor = &mut self.compositor; + let jobs = &mut self.jobs; let mut cx = crate::compositor::Context { - editor: &mut self.editor, - jobs: &mut self.jobs, + editor, + 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.reset(); - - let pos = pos.map(|pos| (pos.col as u16, pos.row as u16)); - self.terminal.draw(pos, kind).unwrap(); + compositor.render(&mut cx); } - pub async fn event_loop<S>(&mut self, input_stream: &mut S) - where - S: Stream<Item = std::io::Result<TerminalEvent>> + Unpin, - { - self.render().await; + pub async fn event_loop(&mut self) { + let mut reader = EventStream::new(); + let mut last_render = Instant::now(); + let deadline = Duration::from_secs(1) / 60; - loop { - if !self.event_loop_until_idle(input_stream).await { - break; - } - } - } + self.render(); - pub async fn event_loop_until_idle<S>(&mut self, input_stream: &mut S) -> bool - where - S: Stream<Item = std::io::Result<TerminalEvent>> + Unpin, - { loop { if self.editor.should_close() { - return false; + self.jobs.finish(); + break; } use futures_util::StreamExt; @@ -328,514 +177,230 @@ impl Application { tokio::select! { biased; - Some(signal) = self.signals.next() => { - if !self.handle_signals(signal).await { - return false; - }; + event = reader.next() => { + self.handle_terminal_events(event) } - Some(event) = input_stream.next() => { - self.handle_terminal_events(event).await; + Some(signal) = self.signals.next() => { + self.handle_signals(signal).await; } - Some(callback) = self.jobs.callbacks.recv() => { - self.jobs.handle_callback(&mut self.editor, &mut self.compositor, Ok(Some(callback))); - self.render().await; + Some((id, call)) = self.editor.language_servers.incoming.next() => { + self.handle_language_server_message(call, id).await; + // limit render calls for fast language server messages + let last = self.editor.language_servers.incoming.is_empty(); + if last || last_render.elapsed() > deadline { + self.render(); + last_render = Instant::now(); + } } - Some(msg) = self.jobs.status_messages.recv() => { - let severity = match msg.severity{ - helix_event::status::Severity::Hint => Severity::Hint, - helix_event::status::Severity::Info => Severity::Info, - helix_event::status::Severity::Warning => Severity::Warning, - helix_event::status::Severity::Error => Severity::Error, - }; - // TODO: show multiple status messages at once to avoid clobbering - self.editor.status_msg = Some((msg.message, severity)); - helix_event::request_redraw(); + Some(callback) = self.jobs.futures.next() => { + self.jobs.handle_callback(&mut self.editor, &mut self.compositor, callback); + self.render(); } Some(callback) = self.jobs.wait_futures.next() => { self.jobs.handle_callback(&mut self.editor, &mut self.compositor, callback); - self.render().await; + self.render(); } - event = self.editor.wait_event() => { - let _idle_handled = self.handle_editor_event(event).await; - - #[cfg(feature = "integration")] - { - if _idle_handled { - return true; - } - } + _ = &mut self.editor.idle_timer => { + // idle timeout + self.editor.clear_idle_timer(); + self.handle_idle_timeout(); } } - - // 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) { - let old_editor_config = self.editor.config(); - - 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).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(&old_editor_config); - - // reset view position in case softwrap was enabled/disabled - let scrolloff = self.editor.config().scrolloff; - for (view, _) in self.editor.tree.views() { - let doc = doc_mut!(self.editor, &view.doc); - view.ensure_cursor_in_view(doc, scrolloff); - } - } - - 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))?; - - // Update the syntax language loader before setting the theme. Setting the theme will - // call `Loader::set_scopes` which must be done before the documents are re-parsed for - // the sake of locals highlighting. - let lang_loader = helix_core::config::user_lang_loader()?; - self.editor.syn_loader.store(Arc::new(lang_loader)); - Self::load_configured_theme( - &mut self.editor, - &default_config, - self.terminal.backend().supports_true_color(), - self.theme_mode, - ); - - // Re-parse any open documents with the new language config. - let lang_loader = self.editor.syn_loader.load(); - for document in self.editor.documents.values_mut() { - // Re-detect .editorconfig - document.detect_editor_config(); - document.detect_language(&lang_loader); - let diagnostics = Editor::doc_diagnostics( - &self.editor.language_servers, - &self.editor.diagnostics, - document, - ); - document.replace_diagnostics(diagnostics, &[], None); - } - - self.terminal.reconfigure((&default_config.editor).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()); - } } } - /// Load the theme set in configuration - fn load_configured_theme( - editor: &mut Editor, - config: &Config, - terminal_true_color: bool, - mode: Option<theme::Mode>, - ) { - let true_color = terminal_true_color || config.editor.true_color || crate::true_color(); - let theme = config - .theme - .as_ref() - .and_then(|theme_config| { - let theme = theme_config.choose(mode); - editor - .theme_loader - .load(theme) - .map_err(|e| { - log::warn!("failed to load theme `{}` - {}", theme, e); - e - }) - .ok() - .filter(|theme| { - let colors_ok = true_color || theme.is_16_color(); - if !colors_ok { - log::warn!( - "loaded theme `{}` but cannot use it because true color \ - support is not enabled", - theme.name() - ); - } - colors_ok - }) - }) - .unwrap_or_else(|| editor.theme_loader.default_theme(true_color)); - editor.set_theme(theme); - } - #[cfg(windows)] // no signal handling available on windows - pub async fn handle_signals(&mut self, _signal: ()) -> bool { - true - } + pub async fn handle_signals(&mut self, _signal: ()) {} #[cfg(not(windows))] - pub async fn handle_signals(&mut self, signal: i32) -> bool { + pub async fn handle_signals(&mut self, signal: i32) { + use helix_view::graphics::Rect; match signal { signal::SIGTSTP => { + self.compositor.save_cursor(); 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); - } + low_level::emulate_default_handler(signal::SIGTSTP).unwrap(); } 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.terminal.claim() { - Ok(()) => break, - Err(err) if retries == 10 => panic!("Failed to claim terminal: {}", err), - Err(_) => continue, - } - } - + self.claim_term().await.unwrap(); // redraw the terminal - let area = self.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; + let Rect { width, height, .. } = self.compositor.size(); + self.compositor.resize(width, height); + self.compositor.load_cursor(); + self.render(); } _ => 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, doc_save_event.save_time); - - let lines = doc_save_event.text.len_lines(); - let size = doc_save_event.text.len_bytes(); + pub fn handle_idle_timeout(&mut self) { + use crate::commands::{insert::idle_completion, Context}; + use helix_view::document::Mode; - enum Size { - Bytes(u16), - HumanReadable(f32, &'static str), + if doc_mut!(self.editor).mode != Mode::Insert || !self.config.editor.auto_completion { + return; } - - impl std::fmt::Display for Size { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::Bytes(bytes) => write!(f, "{bytes}B"), - Self::HumanReadable(size, suffix) => write!(f, "{size:.1}{suffix}"), - } - } + let editor_view = self + .compositor + .find(std::any::type_name::<ui::EditorView>()) + .expect("expected at least one EditorView"); + let editor_view = editor_view + .as_any_mut() + .downcast_mut::<ui::EditorView>() + .unwrap(); + + if editor_view.completion.is_some() { + return; } - let size = if size < 1024 { - Size::Bytes(size as u16) - } else { - const SUFFIX: [&str; 4] = ["B", "KiB", "MiB", "GiB"]; - let mut size = size as f32; - let mut i = 0; - while i < SUFFIX.len() - 1 && size >= 1024.0 { - size /= 1024.0; - i += 1; - } - Size::HumanReadable(size, SUFFIX[i]) + let mut cx = Context { + register: None, + editor: &mut self.editor, + jobs: &mut self.jobs, + count: None, + callback: None, + on_next_key_callback: None, }; - - self.editor - .set_doc_path(doc_save_event.doc_id, &doc_save_event.path); - // TODO: fix being overwritten by lsp - self.editor.set_status(format!( - "'{}' written, {lines}L {size}", - get_relative_path(&doc_save_event.path).to_string_lossy(), - )); + idle_completion(&mut cx); + self.render(); } - #[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((id, payload)) => { - let needs_render = self.editor.handle_debugger_message(id, 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<TerminalEvent>) { - #[cfg(not(windows))] - use termina::escape::csi; - + pub fn handle_terminal_events(&mut self, event: Option<Result<Event, crossterm::ErrorKind>>) { 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() { - #[cfg(not(windows))] - termina::Event::WindowResized(termina::WindowSize { rows, cols, .. }) => { - self.terminal - .resize(Rect::new(0, 0, cols, rows)) - .expect("Unable to resize terminal"); - - let area = self.terminal.size(); - - self.compositor.resize(area); + let should_redraw = match event { + Some(Ok(Event::Resize(width, height))) => { + self.compositor.resize(width, height); self.compositor - .handle_event(&Event::Resize(cols, rows), &mut cx) - } - #[cfg(not(windows))] - // Ignore keyboard release events. - termina::Event::Key(termina::event::KeyEvent { - kind: termina::event::KeyEventKind::Release, - .. - }) => false, - #[cfg(not(windows))] - termina::Event::Csi(csi::Csi::Mode(csi::Mode::ReportTheme(mode))) => { - Self::load_configured_theme( - &mut self.editor, - &self.config.load(), - self.terminal.backend().supports_true_color(), - Some(mode.into()), - ); - true + .handle_event(Event::Resize(width, height), &mut cx) } - #[cfg(windows)] - TerminalEvent::Resize(width, height) => { - self.terminal - .resize(Rect::new(0, 0, width, height)) - .expect("Unable to resize terminal"); - - let area = self.terminal.size(); - - self.compositor.resize(area); - - self.compositor - .handle_event(&Event::Resize(width, height), &mut cx) - } - #[cfg(windows)] - // Ignore keyboard release events. - crossterm::event::Event::Key(crossterm::event::KeyEvent { - kind: crossterm::event::KeyEventKind::Release, - .. - }) => false, - event => self.compositor.handle_event(&event.into(), &mut cx), + Some(Ok(event)) => self.compositor.handle_event(event, &mut cx), + Some(Err(x)) => panic!("{}", x), + None => panic!(), }; if should_redraw && !self.editor.should_close() { - self.render().await; + self.render(); } } pub async fn handle_language_server_message( &mut self, call: helix_lsp::Call, - server_id: LanguageServerId, + 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(helix_lsp::Error::Unhandled) => { - info!("Ignoring Unhandled notification from Language Server"); - return; - } - Err(err) => { - error!( - "Ignoring unknown notification from Language Server: {}", - err - ); - return; - } + Some(notification) => notification, + None => 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() { - language_server.did_change_configuration(config.clone()); - } + let language_server = + match self.editor.language_servers.get_by_id(server_id) { + Some(language_server) => language_server, + None => { + warn!("can't find language server with id `{}`", server_id); + return; + } + }; - helix_event::dispatch(helix_view::events::LanguageServerInitialized { - editor: &mut self.editor, - server_id, + let docs = self.editor.documents().filter(|doc| { + doc.language_server().map(|server| server.id()) == Some(server_id) }); + + // trigger textDocument/didOpen for docs that are already open + for doc in docs { + // TODO: extract and share with editor.open + let language_id = doc + .language() + .and_then(|s| s.split('.').last()) // source.rust + .map(ToOwned::to_owned) + .unwrap_or_default(); + + tokio::spawn(language_server.text_document_did_open( + doc.url().unwrap(), + doc.version(), + doc.text(), + language_id, + )); + } } Notification::PublishDiagnostics(params) => { - let uri = match helix_core::Uri::try_from(params.uri) { - Ok(uri) => uri, - Err(err) => { - log::error!("{err}"); - 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 path = params.uri.to_file_path().unwrap(); + let doc = self.editor.document_by_path_mut(&path); + + if let Some(doc) = doc { + let text = doc.text(); + + let diagnostics = params + .diagnostics + .into_iter() + .filter_map(|diagnostic| { + use helix_core::{ + diagnostic::{Range, Severity::*}, + Diagnostic, + }; + use lsp::DiagnosticSeverity; + + let language_server = doc.language_server().unwrap(); + + // TODO: convert inside server + let start = if let Some(start) = lsp_pos_to_pos( + text, + diagnostic.range.start, + language_server.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, + language_server.offset_encoding(), + ) { + end + } else { + log::warn!("lsp position out of bounds - {:?}", diagnostic); + return None; + }; + + Some(Diagnostic { + range: Range { start, end }, + line: diagnostic.range.start.line as usize, + message: diagnostic.message, + severity: diagnostic.severity.map( + |severity| match severity { + DiagnosticSeverity::Error => Error, + DiagnosticSeverity::Warning => Warning, + DiagnosticSeverity::Information => Info, + DiagnosticSeverity::Hint => Hint, + }, + ), + // code + // source + }) + }) + .collect(); + + doc.set_diagnostics(diagnostics); } - let provider = helix_core::diagnostic::DiagnosticProvider::Lsp { - server_id, - identifier: None, - }; - self.editor.handle_lsp_diagnostics( - &provider, - uri, - params.version, - params.diagnostics, - ); } Notification::ShowMessage(params) => { - if self.config.load().editor.lsp.display_messages { - match params.typ { - lsp::MessageType::ERROR => self.editor.set_error(params.message), - lsp::MessageType::WARNING => { - self.editor.set_warning(params.message) - } - _ => self.editor.set_status(params.message), - } - } + log::warn!("unhandled window/showMessage: {:?}", params); } Notification::LogMessage(params) => { log::info!("window/logMessage: {:?}", params); @@ -847,13 +412,16 @@ impl Application { { let editor_view = self .compositor - .find::<ui::EditorView>() + .find(std::any::type_name::<ui::EditorView>()) .expect("expected at least one EditorView"); - let lsp::ProgressParams { - token, - value: lsp::ProgressParamsValue::WorkDone(work), - } = params; - let (title, message, percentage) = match &work { + let editor_view = editor_view + .as_any_mut() + .downcast_mut::<ui::EditorView>() + .unwrap(); + let lsp::ProgressParams { token, value } = params; + + let lsp::ProgressParamsValue::WorkDone(work) = value; + let parts = match &work { lsp::WorkDoneProgress::Begin(lsp::WorkDoneProgressBegin { title, message, @@ -881,422 +449,151 @@ impl Application { } }; - if self.editor.config().lsp.display_progress_messages { - let title = - title.or_else(|| self.lsp_progress.title(server_id, &token)); - if title.is_some() || percentage.is_some() || message.is_some() { - use std::fmt::Write as _; - let mut status = format!("{}: ", language_server!().name()); - if let Some(percentage) = percentage { - write!(status, "{percentage:>2}% ").unwrap(); - } - if let Some(title) = title { - status.push_str(title); - } - if title.is_some() && message.is_some() { - status.push_str(" ⋅ "); - } - if let Some(message) = message { - status.push_str(message); - } - self.editor.set_status(status); - } - } + let token_d: &dyn std::fmt::Display = match &token { + lsp::NumberOrString::Number(n) => n, + lsp::NumberOrString::String(s) => s, + }; - match work { - lsp::WorkDoneProgress::Begin(begin_status) => { - self.lsp_progress - .begin(server_id, token.clone(), begin_status); + 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) } - lsp::WorkDoneProgress::Report(report_status) => { - self.lsp_progress - .update(server_id, token.clone(), report_status); + (Some(title), None, None) => { + format!("[{}] {}", token_d, title) } - lsp::WorkDoneProgress::End(_) => { - 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(); - }; + (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.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(|(_, provider)| { - provider.language_server_id() != Some(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_for_language_server(server_id); - } - - helix_event::dispatch(helix_view::events::LanguageServerExited { - editor: &mut self.editor, - 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, - }) + let language_server = match self.editor.language_servers.get_by_id(server_id) { + Some(language_server) => language_server, + None => { + warn!("can't find language server with id `{}`", server_id); + return; } - 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, - }) + }; + + let call = match MethodCall::parse(&method, params) { + Some(call) => call, + None => { + error!("Method not found {}", method); + // language_server.reply( + // call.id, + // // TODO: make a Into trait that can cast to Err(jsonrpc::Error) + // Err(helix_lsp::jsonrpc::Error { + // code: helix_lsp::jsonrpc::ErrorCode::MethodNotFound, + // message: "Method not found".to_string(), + // data: None, + // }), + // ); + return; } - Ok(MethodCall::WorkDoneProgressCreate(params)) => { + }; + + match call { + MethodCall::WorkDoneProgressCreate(params) => { self.lsp_progress.create(server_id, params.token); let editor_view = self .compositor - .find::<ui::EditorView>() + .find(std::any::type_name::<ui::EditorView>()) .expect("expected at least one EditorView"); + let editor_view = editor_view + .as_any_mut() + .downcast_mut::<ui::EditorView>() + .unwrap(); 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 = self - .editor - .apply_workspace_edit(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.get_by_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) - } - Ok(MethodCall::ShowDocument(params)) => { - let language_server = language_server!(); - let offset_encoding = language_server.offset_encoding(); - - let result = self.handle_show_document(params, offset_encoding); - Ok(json!(result)) - } - Ok(MethodCall::WorkspaceDiagnosticRefresh) => { - let language_server = language_server!().id(); - - let documents: Vec<_> = self - .editor - .documents - .values() - .filter(|x| x.supports_language_server(language_server)) - .map(|x| x.id()) - .collect(); - - for document in documents { - handlers::diagnostics::request_document_diagnostics( - &mut self.editor, - document, - ); - } - - Ok(serde_json::Value::Null) + tokio::spawn(language_server.reply(id, Ok(serde_json::Value::Null))); } - }; - - let language_server = language_server!(); - if let Err(err) = language_server.reply(id.clone(), reply) { - log::error!( - "Failed to send reply to server '{}' request {id}: {err}", - language_server.name() - ); } } - Call::Invalid { id } => log::error!("LSP invalid method call id={:?}", id), + e => unreachable!("{:?}", e), } } - fn handle_show_document( - &mut self, - params: lsp::ShowDocumentParams, - offset_encoding: helix_lsp::OffsetEncoding, - ) -> lsp::ShowDocumentResult { - if let lsp::ShowDocumentParams { - external: Some(true), - uri, - .. - } = params - { - self.jobs.callback(crate::open_external_url_callback(uri)); - return lsp::ShowDocumentResult { success: true }; - }; - - let lsp::ShowDocumentParams { - uri, - selection, - take_focus, - .. - } = params; - - let uri = match helix_core::Uri::try_from(uri) { - Ok(uri) => uri, - Err(err) => { - log::error!("{err}"); - return lsp::ShowDocumentResult { success: false }; - } - }; - // If `Uri` gets another variant other than `Path` this may not be valid. - let path = uri.as_path().expect("URIs are valid paths"); - - let action = match take_focus { - Some(true) => helix_view::editor::Action::Replace, - _ => helix_view::editor::Action::VerticalSplit, - }; - - let doc_id = match self.editor.open(path, action) { - Ok(id) => id, - Err(err) => { - log::error!("failed to open path: {:?}: {:?}", uri, err); - return lsp::ShowDocumentResult { success: false }; - } - }; - - let doc = doc_mut!(self.editor, &doc_id); - if let Some(range) = selection { - // TODO: convert inside server - if let Some(new_range) = lsp_range_to_range(doc.text(), range, offset_encoding) { - let view = view_mut!(self.editor); - - // we flip the range so that the cursor sits on the start of the symbol - // (for example start of the function). - doc.set_selection(view.id, Selection::single(new_range.head, new_range.anchor)); - if action.align_view(view, doc.id()) { - align_view(doc, view, Align::Center); - } - } else { - log::warn!("lsp position out of bounds - {:?}", range); - }; - }; - lsp::ShowDocumentResult { success: true } - } - - fn restore_term(&mut self) -> std::io::Result<()> { - use helix_view::graphics::CursorKind; - self.terminal - .backend_mut() - .show_cursor(CursorKind::Block) - .ok(); - self.terminal.restore() - } - - #[cfg(all(not(feature = "integration"), not(windows)))] - pub fn event_stream(&self) -> impl Stream<Item = std::io::Result<TerminalEvent>> + Unpin { - use termina::{escape::csi, Terminal as _}; - let reader = self.terminal.backend().terminal().event_reader(); - termina::EventStream::new(reader, |event| { - // Accept either non-escape sequences or theme mode updates. - !event.is_escape() - || matches!( - event, - termina::Event::Csi(csi::Csi::Mode(csi::Mode::ReportTheme(_))) - ) - }) - } - - #[cfg(all(not(feature = "integration"), windows))] - pub fn event_stream(&self) -> impl Stream<Item = std::io::Result<TerminalEvent>> + Unpin { - crossterm::event::EventStream::new() - } - - #[cfg(feature = "integration")] - pub fn event_stream(&self) -> impl Stream<Item = std::io::Result<TerminalEvent>> + Unpin { - use std::{ - pin::Pin, - task::{Context, Poll}, - }; - - /// A dummy stream that never polls as ready. - pub struct DummyEventStream; - - impl Stream for DummyEventStream { - type Item = std::io::Result<TerminalEvent>; - - fn poll_next(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<Option<Self::Item>> { - Poll::Pending - } + async fn claim_term(&mut self) -> Result<(), Error> { + terminal::enable_raw_mode()?; + let mut stdout = stdout(); + execute!(stdout, terminal::EnterAlternateScreen)?; + if self.config.editor.mouse { + execute!(stdout, EnableMouseCapture)?; } - - DummyEventStream + Ok(()) } - pub async fn run<S>(&mut self, input_stream: &mut S) -> Result<i32, Error> - where - S: Stream<Item = std::io::Result<TerminalEvent>> + Unpin, - { - self.terminal.claim()?; - - 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) + fn restore_term(&mut self) -> Result<(), Error> { + let mut stdout = stdout(); + // reset cursor shape + write!(stdout, "\x1B[2 q")?; + // Ignore errors on disabling, this might trigger on windows if we call + // disable without calling enable previously + let _ = execute!(stdout, DisableMouseCapture); + execute!(stdout, terminal::LeaveAlternateScreen)?; + terminal::disable_raw_mode()?; + Ok(()) } - pub async fn close(&mut self) -> Vec<anyhow::Error> { - // [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); - }; + pub async fn run(&mut self) -> Result<(), Error> { + 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`s. + let _ = execute!(std::io::stdout(), DisableMouseCapture); + let _ = execute!(std::io::stdout(), terminal::LeaveAlternateScreen); + let _ = terminal::disable_raw_mode(); + hook(info); + })); - if let Err(err) = self.editor.flush_writes().await { - log::error!("Error writing: {}", err); - errs.push(err); - } + self.event_loop().await; 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" - )); - } + }; + + self.restore_term()?; - errs + Ok(()) } } |