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 | 1235 |
1 files changed, 516 insertions, 719 deletions
diff --git a/helix-term/src/application.rs b/helix-term/src/application.rs index 8c1db649..7a50e007 100644 --- a/helix-term/src/application.rs +++ b/helix-term/src/application.rs @@ -1,15 +1,14 @@ 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_core::{ + diagnostic::{DiagnosticTag, NumberOrString}, + path::get_relative_path, + pos_at_coords, syntax, Selection, }; -use helix_stdx::path::get_relative_path; +use helix_lsp::{lsp, util::lsp_pos_to_pos, LspProgressMap}; use helix_view::{ align_view, - document::{DocumentOpenError, DocumentSavedEventResult}, + document::DocumentSavedEventResult, editor::{ConfigEvent, EditorEvent}, graphics::Rect, theme, @@ -21,51 +20,52 @@ use tui::backend::Backend; use crate::{ args::Args, + commands::apply_workspace_edit, compositor::{Compositor, Event}, config::Config, - handlers, job::Jobs, keymap::Keymaps, - ui::{self, overlay::overlaid}, + ui::{self, overlay::overlayed}, }; -use log::{debug, error, info, warn}; +use log::{debug, error, warn}; use std::{ - io::{stdin, IsTerminal}, - path::Path, + io::{stdin, stdout, Write}, sync::Arc, + time::{Duration, Instant}, }; -#[cfg_attr(windows, allow(unused_imports))] use anyhow::{Context, Error}; +use crossterm::{ + event::{ + DisableBracketedPaste, DisableFocusChange, DisableMouseCapture, EnableBracketedPaste, + EnableFocusChange, EnableMouseCapture, Event as CrosstermEvent, + }, + execute, terminal, + tty::IsTty, +}; #[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; +const LSP_DEADLINE: Duration = Duration::from_millis(16); -#[cfg(all(windows, not(feature = "integration")))] +#[cfg(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(feature = "integration"))] +type Terminal = tui::terminal::Terminal<CrosstermBackend<std::io::Stdout>>; -#[cfg(not(windows))] -type TerminalEvent = termina::Event; -#[cfg(windows)] -type TerminalEvent = crossterm::event::Event; - -type Terminal = tui::terminal::Terminal<TerminalBackend>; +#[cfg(feature = "integration")] +type Terminal = tui::terminal::Terminal<TestBackend>; pub struct Application { compositor: Compositor, @@ -74,11 +74,15 @@ pub struct Application { config: Arc<ArcSwap<Config>>, + #[allow(dead_code)] + theme_loader: Arc<theme::Loader>, + #[allow(dead_code)] + syn_loader: Arc<syntax::Loader>, + signals: Signals, jobs: Jobs, lsp_progress: LspProgressMap, - - theme_mode: Option<theme::Mode>, + last_render: Instant, } #[cfg(feature = "integration")] @@ -103,46 +107,74 @@ fn setup_integration_logging() { .apply(); } +fn restore_term() -> Result<(), Error> { + let mut stdout = stdout(); + // reset cursor shape + write!(stdout, "\x1B[0 q")?; + // Ignore errors on disabling, this might trigger on windows if we call + // disable without calling enable previously + let _ = execute!(stdout, DisableMouseCapture); + execute!( + stdout, + DisableBracketedPaste, + DisableFocusChange, + terminal::LeaveAlternateScreen + )?; + terminal::disable_raw_mode()?; + Ok(()) +} + impl Application { - pub fn new(args: Args, config: Config, lang_loader: syntax::Loader) -> Result<Self, Error> { + pub fn new( + args: Args, + config: Config, + syn_loader_conf: syntax::Configuration, + ) -> Result<Self, Error> { #[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 = theme::Loader::new(&theme_parent_dirs); + let theme_loader = std::sync::Arc::new(theme::Loader::new( + &helix_loader::config_dir(), + &helix_loader::runtime_dir(), + )); - #[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()); + 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()); #[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 area = terminal.size().expect("couldn't get 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| { + theme_loader.clone(), + syn_loader.clone(), + Box::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, ); let keys = Box::new(Map::new(Arc::clone(&config), |config: &Config| { @@ -152,27 +184,23 @@ impl Application { compositor.push(editor_view); if args.load_tutor { - let path = helix_loader::runtime_file(Path::new("tutor")); + let path = helix_loader::runtime_dir().join("tutor"); editor.open(&path, Action::VerticalSplit)?; // Unset path to prevent accidentally saving to the original tutor file. - doc_mut!(editor).set_path(None); + 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; + let first = &args.files[0].0; // we know it's not empty + if first.is_dir() { + std::env::set_current_dir(first).context("set current dir")?; + editor.new_file(Action::VerticalSplit); + let picker = ui::file_picker(".".into(), &config.load().editor); + compositor.push(Box::new(overlayed(picker))); + } else { + let nr_of_files = args.files.len(); + for (i, (file, pos)) in args.files.into_iter().enumerate() { 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 @@ -181,107 +209,91 @@ impl Application { // 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, + _ if i == 0 => 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, - }; + 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 selection = pos - .into_iter() - .map(|coords| { - Range::point(pos_at_coords(doc.text().slice(..), coords, true)) - }) - .collect(); - doc.set_selection(view_id, selection); + let pos = Selection::point(pos_at_coords(doc.text().slice(..), pos, true)); + doc.set_selection(view_id, pos); } } - - // 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)); + // 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 if stdin().is_terminal() || cfg!(feature = "integration") { + } else if stdin().is_tty() || cfg!(feature = "integration") { editor.new_file(Action::VerticalSplit); + } else if cfg!(target_os = "macos") { + // On Linux and Windows, we allow the output of a command to be piped into the new buffer. + // This doesn't currently work on macOS because of the following issue: + // https://github.com/crossterm-rs/crossterm/issues/500 + anyhow::bail!("Piping into helix-term is currently not supported on macOS"); } 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 signals = Signals::new([signal::SIGTSTP, signal::SIGCONT, signal::SIGUSR1]) + .context("build signal handler")?; let app = Self { compositor, terminal, editor, + config, + + theme_loader, + syn_loader, + signals, jobs: Jobs::new(), lsp_progress: LspProgressMap::new(), - theme_mode, + last_render: Instant::now(), }; 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; - } + #[cfg(feature = "integration")] + async fn render(&mut self) {} + #[cfg(not(feature = "integration"))] + async fn render(&mut self) { let mut cx = crate::compositor::Context { editor: &mut self.editor, jobs: &mut self.jobs, scroll: None, }; - helix_event::start_frame(); + // Acquire mutable access to the redraw_handle lock + // to ensure that there are no tasks running that want to block rendering + drop(cx.editor.redraw_handle.1.write().await); cx.editor.needs_redraw = false; + { + // exhaust any leftover redraw notifications + let notify = cx.editor.redraw_handle.0.notified(); + tokio::pin!(notify); + notify.enable(); + } let area = self .terminal @@ -293,19 +305,18 @@ impl Application { 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, kind) = self.compositor.cursor(area, &self.editor); let pos = pos.map(|pos| (pos.col as u16, pos.row as u16)); self.terminal.draw(pos, kind).unwrap(); } pub async fn event_loop<S>(&mut self, input_stream: &mut S) where - S: Stream<Item = std::io::Result<TerminalEvent>> + Unpin, + S: Stream<Item = crossterm::Result<crossterm::event::Event>> + Unpin, { self.render().await; + self.last_render = Instant::now(); loop { if !self.event_loop_until_idle(input_stream).await { @@ -316,7 +327,7 @@ impl Application { pub async fn event_loop_until_idle<S>(&mut self, input_stream: &mut S) -> bool where - S: Stream<Item = std::io::Result<TerminalEvent>> + Unpin, + S: Stream<Item = crossterm::Result<crossterm::event::Event>> + Unpin, { loop { if self.editor.should_close() { @@ -328,28 +339,15 @@ impl Application { 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.callbacks.recv() => { - self.jobs.handle_callback(&mut self.editor, &mut self.compositor, Ok(Some(callback))); - self.render().await; + Some(signal) = self.signals.next() => { + self.handle_signals(signal).await; } - 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().await; } Some(callback) = self.jobs.wait_futures.next() => { self.jobs.handle_callback(&mut self.editor, &mut self.compositor, callback); @@ -377,8 +375,6 @@ impl Application { } 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(), @@ -388,65 +384,43 @@ impl Application { 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); + 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() { - let doc = doc_mut!(self.editor, &view.doc); - view.ensure_cursor_in_view(doc, scrolloff); + /// Refresh theme after config change + fn refresh_theme(&mut self, config: &Config) { + if let Some(theme) = config.theme.clone() { + let true_color = self.true_color(); + match self.theme_loader.load(&theme) { + Ok(theme) => { + if true_color || theme.is_16_color() { + self.editor.set_theme(theme); + } else { + self.editor + .set_error("theme requires truecolor support, which is not available"); + } + } + Err(err) => { + let err_string = format!("failed to load theme `{}` - {}", theme, err); + self.editor.set_error(err_string); + } + } } } 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); - } + match Config::load_default() { + Ok(config) => { + self.refresh_theme(&config); - 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"); + // Store new config + self.config.store(Arc::new(config)); } Err(err) => { self.editor.set_error(err.to_string()); @@ -454,95 +428,31 @@ impl Application { } } - /// 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); + fn true_color(&self) -> bool { + self.config.load().editor.true_color || crate::true_color() } #[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) { 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); - } + // restore cursor + use helix_view::graphics::CursorKind; + self.terminal + .backend_mut() + .show_cursor(CursorKind::Block) + .ok(); + restore_term().unwrap(); + 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(); + let area = self.terminal.size().expect("couldn't get terminal size"); self.compositor.resize(area); self.terminal.clear().expect("couldn't clear terminal"); @@ -552,14 +462,8 @@ impl Application { 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) { @@ -601,44 +505,38 @@ impl Application { doc_save_event.revision ); - doc.set_last_saved_revision(doc_save_event.revision, doc_save_event.save_time); + doc.set_last_saved_revision(doc_save_event.revision); let lines = doc_save_event.text.len_lines(); - let size = doc_save_event.text.len_bytes(); - - enum Size { - Bytes(u16), - HumanReadable(f32, &'static str), - } + let bytes = doc_save_event.text.len_bytes(); + + if doc.path() != Some(&doc_save_event.path) { + if let Err(err) = doc.set_path(Some(&doc_save_event.path)) { + log::error!( + "error setting path for doc '{:?}': {}", + doc.path(), + err.to_string(), + ); - 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}"), - } + self.editor.set_error(err.to_string()); + 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 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); + let _ = self.editor.refresh_language_server(id); + } - 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}", + "'{}' written, {}L {}B", get_relative_path(&doc_save_event.path).to_string_lossy(), + lines, + bytes )); } @@ -658,17 +556,19 @@ impl Application { EditorEvent::LanguageServerMessage((id, call)) => { self.handle_language_server_message(call, id).await; // limit render calls for fast language server messages - helix_event::request_redraw(); + let last = self.editor.language_servers.incoming.is_empty(); + + if last || self.last_render.elapsed() > LSP_DEADLINE { + self.render().await; + self.last_render = Instant::now(); + } } - EditorEvent::DebuggerEvent((id, payload)) => { - let needs_render = self.editor.handle_debugger_message(id, payload).await; + 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; @@ -683,10 +583,10 @@ impl Application { false } - pub async fn handle_terminal_events(&mut self, event: std::io::Result<TerminalEvent>) { - #[cfg(not(windows))] - use termina::escape::csi; - + pub async fn handle_terminal_events( + &mut self, + event: Result<CrosstermEvent, crossterm::ErrorKind>, + ) { let mut cx = crate::compositor::Context { editor: &mut self.editor, jobs: &mut self.jobs, @@ -694,54 +594,18 @@ impl Application { }; // 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); - - 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 - } - #[cfg(windows)] - TerminalEvent::Resize(width, height) => { + CrosstermEvent::Resize(width, height) => { self.terminal .resize(Rect::new(0, 0, width, height)) .expect("Unable to resize terminal"); - let area = self.terminal.size(); + 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) } - #[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), }; @@ -753,33 +617,17 @@ impl Application { 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: {}", + log::error!( + "received malformed notification from Language Server: {}", err ); return; @@ -788,54 +636,166 @@ impl Application { match notification { Notification::Initialized => { - let language_server = language_server!(); + 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; + } + }; // 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()); + tokio::spawn(language_server.did_change_configuration(config.clone())); } - 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 { + 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(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; + Notification::PublishDiagnostics(mut params) => { + let path = params.uri.to_file_path().unwrap(); + let doc = self.editor.document_by_path_mut(&path); + + if let Some(doc) = doc { + let lang_conf = doc.language_config(); + let text = doc.text(); + + let diagnostics = params + .diagnostics + .iter() + .filter_map(|diagnostic| { + use helix_core::diagnostic::{Diagnostic, Range, Severity::*}; + use lsp::DiagnosticSeverity; + + let language_server = if let Some(language_server) = doc.language_server() { + language_server + } else { + log::warn!("Discarding diagnostic because language server is not initialized: {:?}", diagnostic); + return None; + }; + + // 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; + }; + + 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(ref 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() + }; + + Some(Diagnostic { + range: Range { start, end }, + line: diagnostic.range.start.line as usize, + message: diagnostic.message.clone(), + severity, + code, + tags, + source: diagnostic.source.clone(), + data: diagnostic.data.clone(), + }) + }) + .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, - ); + + // 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)); + + // 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. + self.editor + .diagnostics + .insert(params.uri, 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); @@ -849,11 +809,10 @@ impl Application { .compositor .find::<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 lsp::ProgressParams { token, value } = params; + + let lsp::ProgressParamsValue::WorkDone(work) = value; + let parts = match &work { lsp::WorkDoneProgress::Begin(lsp::WorkDoneProgressBegin { title, message, @@ -881,43 +840,47 @@ 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) } - lsp::WorkDoneProgress::Report(report_status) => { - self.lsp_progress - .update(server_id, token.clone(), report_status); + (Some(title), None, Some(percentage)) => { + format!("[{}] {}% {}", token_d, percentage, 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(); - }; + (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) => { @@ -926,26 +889,25 @@ impl Application { 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); - } + let urls: Vec<_> = self + .editor + .documents_mut() + .filter_map(|doc| { + if doc.language_server().map(|server| server.id()) + == Some(server_id) + { + doc.set_diagnostics(Vec::new()); + doc.url() + } else { + None + } + }) + .collect(); - helix_event::dispatch(helix_view::events::LanguageServerExited { - editor: &mut self.editor, - server_id, - }); + for url in urls { + self.editor.diagnostics.remove(&url); + } // Remove the language server from the registry. self.editor.language_servers.remove_by_id(server_id); @@ -955,32 +917,24 @@ impl Application { Call::MethodCall(helix_lsp::jsonrpc::MethodCall { method, params, id, .. }) => { - let reply = match MethodCall::parse(&method, params) { + let call = match MethodCall::parse(&method, params) { + Ok(call) => call, 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, - }) + error!("Language Server: Method not found {}", method); + return; } Err(err) => { log::error!( - "Language Server: Received malformed method call {} in request {}: {}", + "received malformed method call from Language Server: {}: {}", method, - id, err ); - Err(helix_lsp::jsonrpc::Error { - code: helix_lsp::jsonrpc::ErrorCode::ParseError, - message: format!("Malformed method call: {}", method), - data: None, - }) + return; } - Ok(MethodCall::WorkDoneProgressCreate(params)) => { + }; + + let reply = match call { + MethodCall::WorkDoneProgressCreate(params) => { self.lsp_progress.create(server_id, params.token); let editor_view = self @@ -994,47 +948,46 @@ impl Application { 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, - }) - } + MethodCall::ApplyWorkspaceEdit(params) => { + apply_workspace_edit( + &mut self.editor, + helix_lsp::OffsetEncoding::Utf8, + ¶ms.edit, + ); + + Ok(json!(lsp::ApplyWorkspaceEditResponse { + applied: true, + failure_reason: None, + failed_change: None, + })) } - Ok(MethodCall::WorkspaceFolders) => { - Ok(json!(&*language_server!().workspace_folders().await)) + MethodCall::WorkspaceFolders => { + let language_server = + self.editor.language_servers.get_by_id(server_id).unwrap(); + + Ok(json!(language_server.workspace_folders())) } - Ok(MethodCall::WorkspaceConfiguration(params)) => { - let language_server = language_server!(); + MethodCall::WorkspaceConfiguration(params) => { let result: Vec<_> = params .items .iter() .map(|item| { - let mut config = language_server.config()?; + let mut config = match &item.scope_uri { + Some(scope) => { + let path = scope.to_file_path().ok()?; + let doc = self.editor.document_by_path(path)?; + doc.language_config()?.config.as_ref()? + } + None => self + .editor + .language_servers + .get_by_id(server_id) + .unwrap() + .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)?; - } + for part in section.split('.') { + config = config.get(part)?; } } Some(config) @@ -1042,225 +995,69 @@ impl Application { .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) + 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; } }; - 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() - ); - } + tokio::spawn(language_server.reply(id, reply)); } Call::Invalid { id } => log::error!("LSP invalid method call id={:?}", id), } } - 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<()> { + async fn claim_term(&mut self) -> Result<(), Error> { 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 - } + terminal::enable_raw_mode()?; + if self.terminal.cursor_kind() == CursorKind::Hidden { + self.terminal.backend_mut().hide_cursor().ok(); } - - DummyEventStream + let mut stdout = stdout(); + execute!( + stdout, + terminal::EnterAlternateScreen, + EnableBracketedPaste, + EnableFocusChange + )?; + execute!(stdout, terminal::Clear(terminal::ClearType::All))?; + if self.config.load().editor.mouse { + execute!(stdout, EnableMouseCapture)?; + } + Ok(()) } pub async fn run<S>(&mut self, input_stream: &mut S) -> Result<i32, Error> where - S: Stream<Item = std::io::Result<TerminalEvent>> + Unpin, + S: Stream<Item = crossterm::Result<crossterm::event::Event>> + Unpin, { - self.terminal.claim()?; + 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 _ = restore_term(); + hook(info); + })); self.event_loop(input_stream).await; let close_errs = self.close().await; - self.restore_term()?; + // restore cursor + use helix_view::graphics::CursorKind; + self.terminal + .backend_mut() + .show_cursor(CursorKind::Block) + .ok(); + restore_term()?; for err in close_errs { self.editor.exit_code = 1; |