Unnamed repository; edit this file 'description' to name the repository.
-rw-r--r--book/src/keymap.md6
-rw-r--r--book/src/languages.md1
-rw-r--r--helix-core/src/syntax/config.rs2
-rw-r--r--helix-lsp/src/client.rs34
-rw-r--r--helix-term/src/commands.rs96
-rw-r--r--helix-term/src/handlers.rs5
-rw-r--r--helix-term/src/handlers/document_links.rs173
-rw-r--r--helix-term/src/ui/editor.rs37
-rw-r--r--helix-view/src/document.rs14
-rw-r--r--helix-view/src/handlers.rs1
-rw-r--r--helix-view/src/handlers/lsp.rs1
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>&lt;n&gt;&#124;</code> | Go to column number `<n>` | `goto_column` |
| <code>&#124;</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 {