Unnamed repository; edit this file 'description' to name the repository.
| -rw-r--r-- | book/src/keymap.md | 6 | ||||
| -rw-r--r-- | book/src/languages.md | 1 | ||||
| -rw-r--r-- | helix-core/src/syntax/config.rs | 2 | ||||
| -rw-r--r-- | helix-lsp/src/client.rs | 34 | ||||
| -rw-r--r-- | helix-term/src/commands.rs | 96 | ||||
| -rw-r--r-- | helix-term/src/handlers.rs | 5 | ||||
| -rw-r--r-- | helix-term/src/handlers/document_links.rs | 173 | ||||
| -rw-r--r-- | helix-term/src/ui/editor.rs | 37 | ||||
| -rw-r--r-- | helix-view/src/document.rs | 14 | ||||
| -rw-r--r-- | helix-view/src/handlers.rs | 1 | ||||
| -rw-r--r-- | helix-view/src/handlers/lsp.rs | 1 |
11 files changed, 357 insertions, 13 deletions
diff --git a/book/src/keymap.md b/book/src/keymap.md index 37345d6b..d007d2b7 100644 --- a/book/src/keymap.md +++ b/book/src/keymap.md @@ -219,7 +219,7 @@ Jumps to various locations. | <code><n>|</code> | Go to column number `<n>` | `goto_column` | | <code>|</code> | Go to the start of line | `goto_column` | | `e` | Go to the end of the file | `goto_last_line` | -| `f` | Go to files in the selections | `goto_file` | +| `f` | Go to files/URLs in selections | `goto_file` | | `h` | Go to the start of the line | `goto_line_start` | | `l` | Go to the end of the line | `goto_line_end` | | `s` | Go to first non-whitespace character of the line | `goto_first_nonwhitespace` | @@ -267,8 +267,8 @@ This layer is similar to Vim keybindings as Kakoune does not support windows. | `w`, `Ctrl-w` | Switch to next window | `rotate_view` | | `v`, `Ctrl-v` | Vertical right split | `vsplit` | | `s`, `Ctrl-s` | Horizontal bottom split | `hsplit` | -| `f` | Go to files in the selections in horizontal splits | `goto_file` | -| `F` | Go to files in the selections in vertical splits | `goto_file` | +| `f` | Go to files/URLs in selections in horizontal splits | `goto_file` | +| `F` | Go to files/URLs in selections in vertical splits | `goto_file` | | `h`, `Ctrl-h`, `Left` | Move to left split | `jump_view_left` | | `j`, `Ctrl-j`, `Down` | Move to split below | `jump_view_down` | | `k`, `Ctrl-k`, `Up` | Move to split above | `jump_view_up` | diff --git a/book/src/languages.md b/book/src/languages.md index addc907d..3925c42b 100644 --- a/book/src/languages.md +++ b/book/src/languages.md @@ -206,6 +206,7 @@ The list of supported features is: - `document-highlight` - `completion` - `code-action` +- `document-links` - `workspace-command` - `document-symbols` - `workspace-symbols` diff --git a/helix-core/src/syntax/config.rs b/helix-core/src/syntax/config.rs index ad1ddaf0..5cef5ce1 100644 --- a/helix-core/src/syntax/config.rs +++ b/helix-core/src/syntax/config.rs @@ -310,6 +310,7 @@ pub enum LanguageServerFeature { DocumentHighlight, Completion, CodeAction, + DocumentLinks, WorkspaceCommand, DocumentSymbols, WorkspaceSymbols, @@ -337,6 +338,7 @@ impl Display for LanguageServerFeature { DocumentHighlight => "document-highlight", Completion => "completion", CodeAction => "code-action", + DocumentLinks => "document-links", WorkspaceCommand => "workspace-command", DocumentSymbols => "document-symbols", WorkspaceSymbols => "workspace-symbols", diff --git a/helix-lsp/src/client.rs b/helix-lsp/src/client.rs index 7003ca6f..a7dac5d0 100644 --- a/helix-lsp/src/client.rs +++ b/helix-lsp/src/client.rs @@ -364,6 +364,7 @@ impl Client { | CodeActionProviderCapability::Options(_), ) ), + LanguageServerFeature::DocumentLinks => capabilities.document_link_provider.is_some(), LanguageServerFeature::WorkspaceCommand => { capabilities.execute_command_provider.is_some() } @@ -713,6 +714,10 @@ impl Client { dynamic_registration: Some(false), resolve_support: None, }), + document_link: Some(lsp::DocumentLinkClientCapabilities { + dynamic_registration: Some(false), + tooltip_support: Some(false), + }), call_hierarchy: Some(lsp::DynamicRegistrationClientCapabilities { dynamic_registration: Some(false), }), @@ -1174,6 +1179,35 @@ impl Client { Some(self.call::<lsp::request::DocumentColor>(params)) } + pub fn text_document_document_link( + &self, + text_document: lsp::TextDocumentIdentifier, + work_done_token: Option<lsp::ProgressToken>, + ) -> Option<impl Future<Output = Result<Option<Vec<lsp::DocumentLink>>>>> { + if !self.supports_feature(LanguageServerFeature::DocumentLinks) { + return None; + } + + let params = lsp::DocumentLinkParams { + text_document, + work_done_progress_params: lsp::WorkDoneProgressParams { work_done_token }, + partial_result_params: lsp::PartialResultParams::default(), + }; + + Some(self.call::<lsp::request::DocumentLinkRequest>(params)) + } + + pub fn resolve_document_link( + &self, + params: lsp::DocumentLink, + ) -> Option<impl Future<Output = Result<lsp::DocumentLink>>> { + if !self.supports_feature(LanguageServerFeature::DocumentLinks) { + return None; + } + + Some(self.call::<lsp::request::DocumentLinkResolve>(params)) + } + pub fn text_document_hover( &self, text_document: lsp::TextDocumentIdentifier, diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs index e36c694f..7abb5463 100644 --- a/helix-term/src/commands.rs +++ b/helix-term/src/commands.rs @@ -1327,21 +1327,97 @@ fn goto_file_vsplit(cx: &mut Context) { goto_file_impl(cx, Action::VerticalSplit); } -/// Goto files in selection. +/// Returns true when a selection overlaps an LSP document link range. +fn selection_overlaps_document_link( + selection: &Range, + link: &helix_view::document::DocumentLink, +) -> bool { + if selection.is_empty() { + let pos = selection.from(); + link.start <= pos && pos < link.end + } else { + selection.from() < link.end && selection.to() > link.start + } +} + +/// Resolve a document link target, using the LSP resolve request when needed. +fn resolve_document_link_target( + editor: &Editor, + link: &helix_view::document::DocumentLink, +) -> Option<Url> { + if let Some(target) = link.link.target.clone() { + return Some(target); + } + + let language_server = editor.language_server_by_id(link.language_server_id)?; + let supports_resolve = language_server + .capabilities() + .document_link_provider + .as_ref()? + .resolve_provider + .unwrap_or(false); + + if !supports_resolve { + return None; + } + + let future = language_server.resolve_document_link(link.link.clone())?; + helix_lsp::block_on(future).ok()?.target +} + +/// Goto files/URLs in selection. +/// +/// Prefers LSP document links when the cursor/selection overlaps a link range, +/// falling back to the built-in path/URL detection otherwise. fn goto_file_impl(cx: &mut Context, action: Action) { let (view, doc) = current_ref!(cx.editor); - let text = doc.text().slice(..); - let selections = doc.selection(view.id); - let primary = selections.primary(); + let text = doc.text().clone(); + let selections = doc.selection(view.id).ranges().to_vec(); let rel_path = doc .relative_path() .map(|path| path.parent().unwrap().to_path_buf()) .unwrap_or_default(); + let text = text.slice(..); + + let mut lsp_targets = Vec::new(); + let mut lsp_targets_seen = HashSet::new(); + let mut fallback_ranges = Vec::new(); - let paths: Vec<_> = if selections.len() == 1 && primary.len() == 1 { + if doc.document_links.is_empty() { + fallback_ranges.extend_from_slice(&selections); + } else { + for selection in &selections { + let mut matched = false; + for link in &doc.document_links { + if !selection_overlaps_document_link(selection, link) { + continue; + } + matched = true; + if let Some(target) = resolve_document_link_target(cx.editor, link) { + if lsp_targets_seen.insert(target.clone()) { + lsp_targets.push(target); + } + } + } + if !matched { + fallback_ranges.push(*selection); + } + } + } + + for target in lsp_targets { + open_url(cx, target, action); + } + + if fallback_ranges.is_empty() { + return; + } + + let paths: Vec<_> = if fallback_ranges.len() == 1 && fallback_ranges[0].len() == 1 { + let selection = fallback_ranges[0]; // Cap the search at roughly 1k bytes around the cursor. let lookaround = 1000; - let pos = text.char_to_byte(primary.cursor(text)); + let pos = text.char_to_byte(selection.cursor(text)); let search_start = text .line_to_byte(text.byte_to_line(pos)) .max(text.floor_char_boundary(pos.saturating_sub(lookaround))); @@ -1357,13 +1433,13 @@ fn goto_file_impl(cx: &mut Context, action: Action) { .find(|range| pos <= search_start + range.end) .map(|range| Cow::from(search_range.byte_slice(range))); log::debug!("goto_file auto-detected path: {path:?}"); - let path = path.unwrap_or_else(|| primary.fragment(text)); + let path = path.unwrap_or_else(|| selection.fragment(text)); vec![path.into_owned()] } else { // Otherwise use each selection, trimmed. - selections - .fragments(text) - .map(|sel| sel.trim().to_owned()) + fallback_ranges + .iter() + .map(|range| range.fragment(text).trim().to_owned()) .filter(|sel| !sel.is_empty()) .collect() }; diff --git a/helix-term/src/handlers.rs b/helix-term/src/handlers.rs index ff99e7fd..3b2b4c03 100644 --- a/helix-term/src/handlers.rs +++ b/helix-term/src/handlers.rs @@ -13,12 +13,14 @@ use crate::handlers::signature_help::SignatureHelpHandler; pub use helix_view::handlers::{word_index, Handlers}; use self::document_colors::DocumentColorsHandler; +use self::document_links::DocumentLinksHandler; mod auto_save; pub mod completion; pub mod diagnostics; mod document_colors; mod document_highlight; +mod document_links; mod prompt; mod signature_help; mod snippet; @@ -30,6 +32,7 @@ pub fn setup(config: Arc<ArcSwap<Config>>) -> Handlers { let signature_hints = SignatureHelpHandler::new().spawn(); let auto_save = AutoSaveHandler::new().spawn(); let document_colors = DocumentColorsHandler::default().spawn(); + let document_links = DocumentLinksHandler::default().spawn(); let word_index = word_index::Handler::spawn(); let pull_diagnostics = PullDiagnosticsHandler::default().spawn(); let pull_all_documents_diagnostics = PullAllDocumentsDiagnosticHandler::default().spawn(); @@ -39,6 +42,7 @@ pub fn setup(config: Arc<ArcSwap<Config>>) -> Handlers { signature_hints, auto_save, document_colors, + document_links, word_index, pull_diagnostics, pull_all_documents_diagnostics, @@ -52,6 +56,7 @@ pub fn setup(config: Arc<ArcSwap<Config>>) -> Handlers { diagnostics::register_hooks(&handlers); snippet::register_hooks(&handlers); document_colors::register_hooks(&handlers); + document_links::register_hooks(&handlers); prompt::register_hooks(&handlers); handlers } diff --git a/helix-term/src/handlers/document_links.rs b/helix-term/src/handlers/document_links.rs new file mode 100644 index 00000000..5be6176d --- /dev/null +++ b/helix-term/src/handlers/document_links.rs @@ -0,0 +1,173 @@ +use std::{collections::HashSet, time::Duration}; + +use futures_util::{stream::FuturesOrdered, StreamExt}; +use helix_core::{syntax::config::LanguageServerFeature, Assoc}; +use helix_event::{cancelable_future, register_hook}; +use helix_view::{ + document::DocumentLink, + events::{DocumentDidChange, DocumentDidOpen, LanguageServerExited, LanguageServerInitialized}, + handlers::{lsp::DocumentLinksEvent, Handlers}, + DocumentId, Editor, +}; +use tokio::time::Instant; + +use crate::job; + +#[derive(Default)] +pub(super) struct DocumentLinksHandler { + docs: HashSet<DocumentId>, +} + +const DOCUMENT_CHANGE_DEBOUNCE: Duration = Duration::from_millis(250); + +impl helix_event::AsyncHook for DocumentLinksHandler { + type Event = DocumentLinksEvent; + + fn handle_event(&mut self, event: Self::Event, _timeout: Option<Instant>) -> Option<Instant> { + let DocumentLinksEvent(doc_id) = event; + self.docs.insert(doc_id); + Some(Instant::now() + DOCUMENT_CHANGE_DEBOUNCE) + } + + fn finish_debounce(&mut self) { + let docs = std::mem::take(&mut self.docs); + + job::dispatch_blocking(move |editor, _compositor| { + for doc in docs { + request_document_links(editor, doc); + } + }); + } +} + +/// Request document links for a specific document and cache them for navigation. +fn request_document_links(editor: &mut Editor, doc_id: DocumentId) { + let Some(doc) = editor.document_mut(doc_id) else { + return; + }; + + let cancel = doc.document_link_controller.restart(); + + let mut seen_language_servers = HashSet::new(); + let mut futures: FuturesOrdered<_> = doc + .language_servers_with_feature(LanguageServerFeature::DocumentLinks) + .filter(|ls| seen_language_servers.insert(ls.id())) + .filter_map(|language_server| { + let text = doc.text().clone(); + let offset_encoding = language_server.offset_encoding(); + let language_server_id = language_server.id(); + let future = language_server.text_document_document_link(doc.identifier(), None)?; + + Some(async move { + let links = future.await?.unwrap_or_default(); + let links: Vec<_> = links + .into_iter() + .filter_map(|link| { + let start = helix_lsp::util::lsp_pos_to_pos( + &text, + link.range.start, + offset_encoding, + )?; + let end = helix_lsp::util::lsp_pos_to_pos( + &text, + link.range.end, + offset_encoding, + )?; + if start > end { + return None; + } + Some(DocumentLink { + start, + end, + link, + language_server_id, + }) + }) + .collect(); + anyhow::Ok(links) + }) + }) + .collect(); + + if futures.is_empty() { + return; + } + + tokio::spawn(async move { + let mut all_links = Vec::new(); + loop { + match cancelable_future(futures.next(), &cancel).await { + Some(Some(Ok(items))) => all_links.extend(items), + Some(Some(Err(err))) => log::error!("document link request failed: {err}"), + Some(None) => break, + None => return, + } + } + + job::dispatch(move |editor, _| attach_document_links(editor, doc_id, all_links)).await; + }); +} + +fn attach_document_links(editor: &mut Editor, doc_id: DocumentId, mut links: Vec<DocumentLink>) { + let Some(doc) = editor.documents.get_mut(&doc_id) else { + return; + }; + + if links.is_empty() { + doc.document_links.clear(); + return; + } + + links.sort_by_key(|link| (link.start, link.end)); + doc.document_links = links; +} + +pub(super) fn register_hooks(handlers: &Handlers) { + register_hook!(move |event: &mut DocumentDidOpen<'_>| { + request_document_links(event.editor, event.doc); + Ok(()) + }); + + let tx = handlers.document_links.clone(); + register_hook!(move |event: &mut DocumentDidChange<'_>| { + event + .changes + .update_positions(event.doc.document_links.iter_mut().flat_map(|link| { + std::iter::once((&mut link.start, Assoc::After)) + .chain(std::iter::once((&mut link.end, Assoc::After))) + })); + + if !event.ghost_transaction { + event.doc.document_link_controller.cancel(); + helix_event::send_blocking(&tx, DocumentLinksEvent(event.doc.id())); + } + + Ok(()) + }); + + register_hook!(move |event: &mut LanguageServerInitialized<'_>| { + let doc_ids: Vec<_> = event.editor.documents().map(|doc| doc.id()).collect(); + + for doc_id in doc_ids { + request_document_links(event.editor, doc_id); + } + + Ok(()) + }); + + register_hook!(move |event: &mut LanguageServerExited<'_>| { + for doc in event.editor.documents_mut() { + if doc.supports_language_server(event.server_id) { + doc.document_links.clear(); + } + } + + let doc_ids: Vec<_> = event.editor.documents().map(|doc| doc.id()).collect(); + + for doc_id in doc_ids { + request_document_links(event.editor, doc_id); + } + + Ok(()) + }); +} diff --git a/helix-term/src/ui/editor.rs b/helix-term/src/ui/editor.rs index c1d8205e..3626c5dd 100644 --- a/helix-term/src/ui/editor.rs +++ b/helix-term/src/ui/editor.rs @@ -139,6 +139,10 @@ impl EditorView { } } + if let Some(overlay) = Self::doc_document_link_highlights(doc, theme) { + overlays.push(overlay); + } + Self::doc_diagnostics_highlights_into(doc, theme, &mut overlays); if is_focused { @@ -485,6 +489,39 @@ impl EditorView { }) } + pub fn doc_document_link_highlights( + doc: &Document, + theme: &Theme, + ) -> Option<OverlayHighlights> { + let highlight = theme + .find_highlight_exact("markup.link.url") + .or_else(|| theme.find_highlight_exact("markup.link"))?; + + if doc.document_links.is_empty() { + return None; + } + + let mut ranges: Vec<ops::Range<usize>> = Vec::new(); + for link in &doc.document_links { + if link.start >= link.end { + continue; + } + + match ranges.last_mut() { + Some(existing_range) if link.start <= existing_range.end => { + existing_range.end = existing_range.end.max(link.end); + } + _ => ranges.push(link.start..link.end), + } + } + + if ranges.is_empty() { + return None; + } + + Some(OverlayHighlights::Homogeneous { highlight, ranges }) + } + /// Get highlight spans for selections in a document view. pub fn doc_selection_highlights( mode: Mode, diff --git a/helix-view/src/document.rs b/helix-view/src/document.rs index 7a67ac7f..cf7f7bf8 100644 --- a/helix-view/src/document.rs +++ b/helix-view/src/document.rs @@ -211,12 +211,15 @@ pub struct Document { /// Annotations for LSP document color swatches pub color_swatches: Option<DocumentColorSwatches>, + /// Cached LSP document links for navigation (e.g. goto_file). + pub document_links: Vec<DocumentLink>, // NOTE: ideally this would live on the handler for color swatches. This is blocked on a // large refactor that would make `&mut Editor` available on the `DocumentDidChange` event. pub color_swatch_controller: TaskController, /// Per-view task controllers for canceling in-flight document highlight requests. pub document_highlight_controllers: HashMap<ViewId, TaskController>, pub pull_diagnostic_controller: TaskController, + pub document_link_controller: TaskController, // NOTE: this field should eventually go away - we should use the Editor's syn_loader instead // of storing a copy on every doc. Then we can remove the surrounding `Arc` and use the @@ -237,6 +240,15 @@ pub struct DocumentHighlights { pub ranges: Vec<std::ops::Range<usize>>, } +#[derive(Debug, Clone)] +pub struct DocumentLink { + /// Character offsets in the document for the link range. + pub start: usize, + pub end: usize, + pub link: lsp::DocumentLink, + pub language_server_id: LanguageServerId, +} + /// Inlay hints for a single `(Document, View)` combo. /// /// There are `*_inlay_hints` field for each kind of hints an LSP can send since we offer the @@ -741,11 +753,13 @@ impl Document { jump_labels: HashMap::new(), document_highlights: HashMap::new(), color_swatches: None, + document_links: Vec::new(), color_swatch_controller: TaskController::new(), document_highlight_controllers: HashMap::new(), syn_loader, previous_diagnostic_id: None, pull_diagnostic_controller: TaskController::new(), + document_link_controller: TaskController::new(), } } diff --git a/helix-view/src/handlers.rs b/helix-view/src/handlers.rs index 6f3ad1ed..e269a695 100644 --- a/helix-view/src/handlers.rs +++ b/helix-view/src/handlers.rs @@ -23,6 +23,7 @@ pub struct Handlers { pub signature_hints: Sender<lsp::SignatureHelpEvent>, pub auto_save: Sender<AutoSaveEvent>, pub document_colors: Sender<lsp::DocumentColorsEvent>, + pub document_links: Sender<lsp::DocumentLinksEvent>, pub word_index: word_index::Handler, pub pull_diagnostics: Sender<lsp::PullDiagnosticsEvent>, pub pull_all_documents_diagnostics: Sender<lsp::PullAllDocumentsDiagnosticsEvent>, diff --git a/helix-view/src/handlers/lsp.rs b/helix-view/src/handlers/lsp.rs index 96ab4626..b052138f 100644 --- a/helix-view/src/handlers/lsp.rs +++ b/helix-view/src/handlers/lsp.rs @@ -16,6 +16,7 @@ use helix_lsp::{lsp, LanguageServerId, OffsetEncoding}; use super::Handlers; pub struct DocumentColorsEvent(pub DocumentId); +pub struct DocumentLinksEvent(pub DocumentId); #[derive(Debug, PartialEq, Eq, Clone, Copy)] pub enum SignatureHelpInvoked { |