diff --git a/book/src/configuration.md b/book/src/configuration.md index 0cd12568b..0a2ca3a2e 100644 --- a/book/src/configuration.md +++ b/book/src/configuration.md @@ -32,4 +32,3 @@ signal to the Helix process on Unix operating systems, such as by using the comm Finally, you can have a `config.toml` local to a project by putting it under a `.helix` directory in your repository. Its settings will be merged with the configuration directory `config.toml` and the built-in configuration. - diff --git a/helix-core/src/syntax.rs b/helix-core/src/syntax.rs index 93f618c09..a4d12cbde 100644 --- a/helix-core/src/syntax.rs +++ b/helix-core/src/syntax.rs @@ -331,6 +331,7 @@ pub enum LanguageServerFeature { Diagnostics, RenameSymbol, InlayHints, + CodeLens, } impl Display for LanguageServerFeature { @@ -354,6 +355,7 @@ impl Display for LanguageServerFeature { Diagnostics => "diagnostics", RenameSymbol => "rename-symbol", InlayHints => "inlay-hints", + CodeLens => "code-lens", }; write!(f, "{feature}",) } diff --git a/helix-lsp/src/client.rs b/helix-lsp/src/client.rs index 643aa9a26..e25d20276 100644 --- a/helix-lsp/src/client.rs +++ b/helix-lsp/src/client.rs @@ -354,6 +354,7 @@ impl Client { capabilities.inlay_hint_provider, Some(OneOf::Left(true) | OneOf::Right(InlayHintServerCapabilities::Options(_))) ), + LanguageServerFeature::CodeLens => capabilities.code_lens_provider.is_some(), } } @@ -662,6 +663,9 @@ impl Client { dynamic_registration: Some(false), resolve_support: None, }), + code_lens: Some(lsp::CodeLensClientCapabilities { + ..Default::default() + }), ..Default::default() }), window: Some(lsp::WindowClientCapabilities { @@ -1549,4 +1553,45 @@ impl Client { changes, }) } + + pub fn code_lens( + &self, + text_document: lsp::TextDocumentIdentifier, + ) -> Option>> { + let capabilities = self.capabilities.get().unwrap(); + + // Return early if the server does not support code lens. + capabilities.code_lens_provider.as_ref()?; + + let params = lsp::CodeLensParams { + text_document, + work_done_progress_params: lsp::WorkDoneProgressParams::default(), + partial_result_params: lsp::PartialResultParams::default(), + }; + + Some(self.call::(params)) + } + + pub fn code_lens_resolve( + &self, + code_lens: lsp::CodeLens, + ) -> Option>>> { + let capabilities = self.capabilities.get().unwrap(); + + // Return early if the server does not support resolving code lens. + match capabilities.code_lens_provider { + Some(lsp::CodeLensOptions { + resolve_provider: Some(true), + .. + }) => (), + _ => return None, + } + + let request = self.call::(code_lens); + Some(async move { + let json = request.await?; + let response: Option = serde_json::from_value(json)?; + Ok(response) + }) + } } diff --git a/helix-lsp/src/lib.rs b/helix-lsp/src/lib.rs index ec89e1f82..84d6bd32a 100644 --- a/helix-lsp/src/lib.rs +++ b/helix-lsp/src/lib.rs @@ -13,8 +13,9 @@ pub use lsp::{Position, Url}; pub use lsp_types as lsp; use futures_util::stream::select_all::SelectAll; -use helix_core::syntax::{ - LanguageConfiguration, LanguageServerConfiguration, LanguageServerFeatures, +use helix_core::{ + diagnostic::Range, + syntax::{LanguageConfiguration, LanguageServerConfiguration, LanguageServerFeatures}, }; use helix_stdx::path; use slotmap::SlotMap; @@ -1134,3 +1135,13 @@ mod tests { assert!(transaction.apply(&mut source)); } } + +/// Corresponds to [`lsp_types::CodeLense`](https://docs.rs/lsp-types/0.94.0/lsp_types/struct.Diagnostic.html) +#[derive(Debug, Clone)] +pub struct CodeLens { + pub range: Range, + pub line: usize, + pub data: Option, + pub language_server_id: LanguageServerId, + pub command: Option, +} diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs index 7e0bee92b..45d025590 100644 --- a/helix-term/src/commands.rs +++ b/helix-term/src/commands.rs @@ -422,6 +422,8 @@ impl MappableCommand { paste_after, "Paste after selection", paste_before, "Paste before selection", paste_clipboard_after, "Paste clipboard after selections", + code_lens_under_cursor, "Show code lenses under cursor", + code_lenses_picker, "Show code lense picker", paste_clipboard_before, "Paste clipboard before selections", paste_primary_clipboard_after, "Paste primary clipboard after selections", paste_primary_clipboard_before, "Paste primary clipboard before selections", diff --git a/helix-term/src/commands/lsp.rs b/helix-term/src/commands/lsp.rs index 3b9efb431..e18b55344 100644 --- a/helix-term/src/commands/lsp.rs +++ b/helix-term/src/commands/lsp.rs @@ -5,8 +5,8 @@ use helix_lsp::{ self, CodeAction, CodeActionOrCommand, CodeActionTriggerKind, DiagnosticSeverity, NumberOrString, }, - util::{diagnostic_to_lsp_diagnostic, lsp_range_to_range, range_to_lsp_range}, - Client, LanguageServerId, OffsetEncoding, + util::{diagnostic_to_lsp_diagnostic, lsp_pos_to_pos, lsp_range_to_range, range_to_lsp_range}, + Client, CodeLens, LanguageServerId, OffsetEncoding, }; use tokio_stream::StreamExt; use tui::{text::Span, widgets::Row}; @@ -14,8 +14,9 @@ use tui::{text::Span, widgets::Row}; use super::{align_view, push_jump, Align, Context, Editor}; use helix_core::{ - syntax::LanguageServerFeature, text_annotations::InlineAnnotation, Selection, Uri, + syntax::LanguageServerFeature, text_annotations::InlineAnnotation, Rope, Selection, Uri, }; +pub use helix_lsp::lsp::Command; use helix_stdx::path; use helix_view::{ document::{DocumentInlayHints, DocumentInlayHintsId}, @@ -61,6 +62,17 @@ macro_rules! language_server_with_feature { }}; } +impl ui::menu::Item for CodeLens { + type Data = (); + + fn format(&self, _: &Self::Data) -> Row { + match self.command.clone() { + Some(cmd) => cmd.title.into(), + None => "unresolved".into(), + } + } +} + struct SymbolInformationItem { symbol: lsp::SymbolInformation, offset_encoding: OffsetEncoding, @@ -1423,3 +1435,203 @@ fn compute_inlay_hints_for_view( Some(callback) } + +fn map_code_lens( + doc_text: &Rope, + cl: &lsp::CodeLens, + offset_enc: OffsetEncoding, + language_server_id: LanguageServerId, +) -> CodeLens { + use helix_core::diagnostic::Range; + let start = lsp_pos_to_pos(doc_text, cl.range.start, offset_enc).unwrap(); + CodeLens { + range: Range { + start, + end: lsp_pos_to_pos(doc_text, cl.range.end, offset_enc).unwrap(), + }, + line: doc_text.char_to_line(start), + data: cl.data.clone(), + language_server_id, + command: cl.command.clone(), + } +} + +pub fn code_lens_under_cursor(cx: &mut Context) { + let (view, doc) = current!(cx.editor); + let doc_text = doc.text(); + + let language_server = + language_server_with_feature!(cx.editor, doc, LanguageServerFeature::CodeLens); + + let offset_encoding = language_server.offset_encoding(); + let pos = doc.position(view.id, offset_encoding); + let uri = match doc.uri() { + Some(uri) => uri, + None => { + return; + } + }; + + if let Some(lenses) = cx.editor.code_lenses.get(&uri) { + let lenses: Vec = lenses + .iter() + .filter(|cl| { + // TODO: fix the check + cl.range.start.line == pos.line + }) + .map(|cl| { + // if cl.command.is_none() { + // if let Some(req) = language_server.code_lens_resolve(cl.clone()) { + // if let Some(code_lens) = block_on(req).ok().unwrap() { + // log::info!("code_lense: resolved {:?} into {:?}", cl, code_lens); + // return map_code_lens(doc, &code_lens); + // } + // } + // } + map_code_lens(doc_text, cl, offset_encoding, language_server.id()) + }) + .collect(); + + if lenses.is_empty() { + cx.editor.set_status("No code lens available"); + return; + } + + let mut picker = ui::Menu::new(lenses, (), move |editor, code_lens, event| { + if event != PromptEvent::Validate { + return; + } + + let code_lens = code_lens.unwrap(); + let Some(language_server) = editor.language_server_by_id(code_lens.language_server_id) + else { + editor.set_error("Language Server disappeared"); + return; + }; + + let lens = code_lens.clone(); + if let Some(cmd) = lens.command { + let future = match language_server.command(cmd) { + Some(future) => future, + None => { + editor.set_error("Language server does not support executing commands"); + return; + } + }; + + tokio::spawn(async move { + let res = future.await; + + if let Err(e) = res { + log::error!("execute LSP command: {}", e); + } + }); + } + }); + picker.move_down(); // pre-select the first item + + let popup = Popup::new("code-lens", picker).with_scrollbar(false); + cx.push_layer(Box::new(popup)); + }; +} + +pub fn code_lenses_picker(cx: &mut Context) { + let doc = doc!(cx.editor); + + let language_server = + language_server_with_feature!(cx.editor, doc, LanguageServerFeature::CodeLens); + let language_server_id = language_server.id(); + + let request = match language_server.code_lens(doc.identifier()) { + Some(future) => future, + None => { + cx.editor + .set_error("Language server does not support code lens"); + return; + } + }; + + let doc_id = doc.id(); + let offset_enc = language_server.offset_encoding(); + + cx.callback( + request, + move |editor, compositor, lenses: Option>| { + if let Some(lenses) = lenses { + let doc = doc_mut!(editor, &doc_id); + let doc_text = doc.text(); + if let Some(uri) = doc.uri() { + editor.code_lenses.insert(uri, lenses.clone()); + }; + + let lenses: Vec = lenses + .iter() + .map(|l| map_code_lens(doc_text, l, offset_enc, language_server_id)) + .collect(); + log::error!("lenses got: {:?}", lenses); + doc.set_code_lens(lenses.clone()); + + let columns = [ + ui::PickerColumn::new("title", |item: &CodeLens, _| match &item.command { + Some(cmd) => cmd.title.clone().into(), + None => "".into(), + }), + ui::PickerColumn::new("command", |item: &CodeLens, _| match &item.command { + Some(Command { + command, + arguments: None, + .. + }) => command.clone().into(), + Some(Command { + command, + arguments: Some(arguments), + .. + }) => format!( + "{} {}", + command, + arguments + .iter() + .map(|a| a.to_string()) + .collect::>() + .join(" ") + ) + .into(), + None => "".into(), + }), + ]; + + let picker = Picker::new(columns, 1, lenses, (), |cx, meta, _action| { + let doc = doc!(cx.editor); + let language_server = language_server_with_feature!( + cx.editor, + doc, + LanguageServerFeature::CodeLens + ); + + if let Some(cmd) = meta.command.clone() { + let future = match language_server.command(cmd) { + Some(future) => future, + None => { + cx.editor.set_error( + "Language server does not support executing commands", + ); + return; + } + }; + tokio::spawn(async move { + let res = future.await; + + if let Err(e) = res { + log::error!("execute LSP command: {}", e); + } + }); + } + }); + + compositor.push(Box::new(picker)); + } else { + editor.set_status("no lens found"); + } + }, + ) +} diff --git a/helix-term/src/keymap/default.rs b/helix-term/src/keymap/default.rs index 5a3e8eed4..6022be0ab 100644 --- a/helix-term/src/keymap/default.rs +++ b/helix-term/src/keymap/default.rs @@ -231,6 +231,8 @@ pub fn default() -> HashMap { "a" => code_action, "'" => last_picker, "G" => { "Debug (experimental)" sticky=true + "l" => code_lens_under_cursor, + "L" => code_lenses_picker, "l" => dap_launch, "r" => dap_restart, "b" => dap_toggle_breakpoint, diff --git a/helix-term/src/ui/editor.rs b/helix-term/src/ui/editor.rs index c151a7dd5..33873394c 100644 --- a/helix-term/src/ui/editor.rs +++ b/helix-term/src/ui/editor.rs @@ -352,6 +352,27 @@ impl EditorView { text_annotations.collect_overlay_highlights(range) } + pub fn doc_code_lens_highlights( + doc: &Document, + theme: &Theme, + ) -> Option)>> { + let idx = theme + .find_scope_index("code_lens") + // get one of the themes below as fallback values + .or_else(|| theme.find_scope_index("diagnostic")) + .or_else(|| theme.find_scope_index("ui.cursor")) + .or_else(|| theme.find_scope_index("ui.selection")) + .expect( + "at least one of the following scopes must be defined in the theme: `diagnostic`, `ui.cursor`, or `ui.selection`", + ); + Some( + doc.code_lens() + .iter() + .map(|l| (idx, l.range.start..l.range.end)) + .collect(), + ) + } + /// Get highlight spans for document diagnostics pub fn doc_diagnostics_highlights( doc: &Document, diff --git a/helix-view/src/document.rs b/helix-view/src/document.rs index f3ace89e5..01d87f882 100644 --- a/helix-view/src/document.rs +++ b/helix-view/src/document.rs @@ -179,6 +179,7 @@ pub struct Document { pub(crate) diagnostics: Vec, pub(crate) language_servers: HashMap>, + pub(crate) code_lens: Vec, diff_handle: Option, version_control_head: Option>>>, @@ -633,7 +634,7 @@ where *mut_ref = f(mem::take(mut_ref)); } -use helix_lsp::{lsp, Client, LanguageServerId, LanguageServerName}; +use helix_lsp::{lsp, Client, CodeLens, LanguageServerId, LanguageServerName}; use url::Url; impl Document { @@ -664,6 +665,7 @@ impl Document { changes, old_state, diagnostics: Vec::new(), + code_lens: Vec::new(), version: 0, history: Cell::new(History::default()), savepoints: Vec::new(), @@ -1883,6 +1885,15 @@ impl Document { }) } + #[inline] + pub fn code_lens(&self) -> &[CodeLens] { + &self.code_lens + } + + pub fn set_code_lens(&mut self, code_lens: Vec) { + self.code_lens = code_lens; + } + #[inline] pub fn diagnostics(&self) -> &[Diagnostic] { &self.diagnostics diff --git a/helix-view/src/editor.rs b/helix-view/src/editor.rs index cead30d7c..404732f68 100644 --- a/helix-view/src/editor.rs +++ b/helix-view/src/editor.rs @@ -435,6 +435,8 @@ pub struct LspConfig { pub snippets: bool, /// Whether to include declaration in the goto reference query pub goto_reference_include_declaration: bool, + /// Enable code lense. + pub code_lens: bool, } impl Default for LspConfig { @@ -447,6 +449,7 @@ impl Default for LspConfig { display_inlay_hints: false, snippets: true, goto_reference_include_declaration: true, + code_lens: false, } } } @@ -1029,6 +1032,7 @@ pub struct Editor { pub macro_replaying: Vec, pub language_servers: helix_lsp::Registry, pub diagnostics: BTreeMap>, + pub code_lenses: BTreeMap>, pub diff_providers: DiffProviderRegistry, pub debugger: Option, @@ -1174,6 +1178,7 @@ impl Editor { theme: theme_loader.default(), language_servers, diagnostics: BTreeMap::new(), + code_lenses: BTreeMap::new(), diff_providers: DiffProviderRegistry::default(), debugger: None, debugger_events: SelectAll::new(), diff --git a/theme.toml b/theme.toml index c1e5883d0..74f8872fe 100644 --- a/theme.toml +++ b/theme.toml @@ -86,6 +86,8 @@ label = "honey" "diagnostic.unnecessary" = { modifiers = ["dim"] } "diagnostic.deprecated" = { modifiers = ["crossed_out"] } +"code_lens" = { modifiers = ["underline"] } + warning = "lightning" error = "apricot" info = "delta"