Unnamed repository; edit this file 'description' to name the repository.
Diffstat (limited to 'helix-term/src/commands/lsp.rs')
| -rw-r--r-- | helix-term/src/commands/lsp.rs | 525 |
1 files changed, 138 insertions, 387 deletions
diff --git a/helix-term/src/commands/lsp.rs b/helix-term/src/commands/lsp.rs index 0494db3e..773957c6 100644 --- a/helix-term/src/commands/lsp.rs +++ b/helix-term/src/commands/lsp.rs @@ -1,21 +1,14 @@ use futures_util::{stream::FuturesOrdered, FutureExt}; use helix_lsp::{ - block_on, - lsp::{ - self, CodeAction, CodeActionOrCommand, CodeActionTriggerKind, DiagnosticSeverity, - NumberOrString, - }, - util::{diagnostic_to_lsp_diagnostic, lsp_range_to_range, range_to_lsp_range}, - Client, LanguageServerId, OffsetEncoding, + block_on, lsp, util::lsp_range_to_range, Client, LanguageServerId, OffsetEncoding, }; use tokio_stream::StreamExt; -use tui::{text::Span, widgets::Row}; +use tui::text::Span; use super::{align_view, push_jump, Align, Context, Editor}; use helix_core::{ - diagnostic::DiagnosticProvider, syntax::config::LanguageServerFeature, - text_annotations::InlineAnnotation, Selection, Uri, + syntax::config::LanguageServerFeature, text_annotations::InlineAnnotation, Selection, Uri, }; use helix_stdx::path; use helix_view::{ @@ -23,7 +16,7 @@ use helix_view::{ editor::Action, handlers::lsp::SignatureHelpInvoked, theme::Style, - Document, View, + Diagnostic, Document, DocumentId, View, }; use crate::{ @@ -32,7 +25,7 @@ use crate::{ ui::{self, overlay::overlaid, FileLocation, Picker, Popup, PromptEvent}, }; -use std::{cmp::Ordering, collections::HashSet, fmt::Display, future::Future, path::Path}; +use std::{collections::HashSet, fmt::Display, future::Future}; /// Gets the first language server that is attached to a document which supports a specific feature. /// If there is no configured language server that supports the feature, this displays a status message. @@ -56,31 +49,48 @@ macro_rules! language_server_with_feature { }}; } -/// A wrapper around `lsp::Location` that swaps out the LSP URI for `helix_core::Uri` and adds -/// the server's offset encoding. +/// A wrapper around `lsp::Location`. #[derive(Debug, Clone, PartialEq, Eq)] -struct Location { +pub struct Location { uri: Uri, - range: lsp::Range, - offset_encoding: OffsetEncoding, + range: helix_view::Range, } -fn lsp_location_to_location( - location: lsp::Location, - offset_encoding: OffsetEncoding, -) -> Option<Location> { - let uri = match location.uri.try_into() { - Ok(uri) => uri, - Err(err) => { - log::warn!("discarding invalid or unsupported URI: {err}"); - return None; - } - }; - Some(Location { - uri, - range: location.range, - offset_encoding, - }) +impl Location { + fn lsp(location: lsp::Location, offset_encoding: OffsetEncoding) -> Option<Self> { + let uri = match location.uri.try_into() { + Ok(uri) => uri, + Err(err) => { + log::warn!("discarding invalid or unsupported URI: {err}"); + return None; + } + }; + Some(Self { + uri, + range: helix_view::Range::Lsp { + range: location.range, + offset_encoding, + }, + }) + } + + fn file_location<'a>(&'a self, editor: &Editor) -> Option<FileLocation<'a>> { + let (path_or_id, doc) = match &self.uri { + Uri::File(path) => ((&**path).into(), None), + Uri::Scratch(doc_id) => ((*doc_id).into(), editor.documents.get(doc_id)), + _ => return None, + }; + let lines = match self.range { + helix_view::Range::Lsp { range, .. } => { + Some((range.start.line as usize, range.end.line as usize)) + } + helix_view::Range::Document(range) => doc.map(|doc| { + let text = doc.text().slice(..); + (text.char_to_line(range.start), text.char_to_line(range.end)) + }), + }; + Some((path_or_id, lines)) + } } struct SymbolInformationItem { @@ -97,63 +107,57 @@ struct DiagnosticStyles { struct PickerDiagnostic { location: Location, - diag: lsp::Diagnostic, -} - -fn location_to_file_location(location: &Location) -> Option<FileLocation<'_>> { - let path = location.uri.as_path()?; - let line = Some(( - location.range.start.line as usize, - location.range.end.line as usize, - )); - Some((path.into(), line)) + diag: Diagnostic, } fn jump_to_location(editor: &mut Editor, location: &Location, action: Action) { let (view, doc) = current!(editor); push_jump(view, doc); - let Some(path) = location.uri.as_path() else { - let err = format!("unable to convert URI to filepath: {:?}", location.uri); - editor.set_error(err); - return; + let doc_id = match &location.uri { + Uri::Scratch(doc_id) => { + editor.switch(*doc_id, action); + *doc_id + } + Uri::File(path) => match editor.open(path, action) { + Ok(doc_id) => doc_id, + Err(err) => { + editor.set_error(format!("failed to open path: {:?}: {:?}", path, err)); + return; + } + }, + _ => return, }; - jump_to_position( - editor, - path, - location.range, - location.offset_encoding, - action, - ); + + jump_to_position(editor, doc_id, location.range, action); } fn jump_to_position( editor: &mut Editor, - path: &Path, - range: lsp::Range, - offset_encoding: OffsetEncoding, + doc_id: DocumentId, + range: helix_view::Range, action: Action, ) { - let doc = match editor.open(path, action) { - Ok(id) => doc_mut!(editor, &id), - Err(err) => { - let err = format!("failed to open path: {:?}: {:?}", path, err); - editor.set_error(err); - return; - } + let Some(doc) = editor.documents.get_mut(&doc_id) else { + return; }; let view = view_mut!(editor); - // TODO: convert inside server - let new_range = if let Some(new_range) = lsp_range_to_range(doc.text(), range, offset_encoding) - { - new_range - } else { - log::warn!("lsp position out of bounds - {:?}", range); - return; + let selection = match range { + helix_view::Range::Lsp { + range, + offset_encoding, + } => { + let Some(range) = lsp_range_to_range(doc.text(), range, offset_encoding) else { + log::warn!("lsp position out of bounds - {:?}", range); + return; + }; + range.into() + } + helix_view::Range::Document(range) => Selection::single(range.start, range.end), }; // 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)); + doc.set_selection(view.id, selection); if action.align_view(view, doc.id()) { align_view(doc, view, Align::Center); } @@ -204,40 +208,25 @@ type DiagnosticsPicker = Picker<PickerDiagnostic, DiagnosticStyles>; fn diag_picker( cx: &Context, - diagnostics: impl IntoIterator<Item = (Uri, Vec<(lsp::Diagnostic, DiagnosticProvider)>)>, + diagnostics: impl IntoIterator<Item = (Uri, Vec<Diagnostic>)>, format: DiagnosticsFormat, ) -> DiagnosticsPicker { - // TODO: drop current_path comparison and instead use workspace: bool flag? - // flatten the map to a vec of (url, diag) pairs let mut flat_diag = Vec::new(); for (uri, diags) in diagnostics { flat_diag.reserve(diags.len()); - for (diag, provider) in diags { - if let Some(ls) = provider - .language_server_id() - .and_then(|id| cx.editor.language_server_by_id(id)) - { - flat_diag.push(PickerDiagnostic { - location: Location { - uri: uri.clone(), - range: diag.range, - offset_encoding: ls.offset_encoding(), - }, - diag, - }); - } + for diag in diags { + flat_diag.push(PickerDiagnostic { + location: Location { + uri: uri.clone(), + range: diag.range, + }, + diag, + }); } } - flat_diag.sort_by(|a, b| { - a.diag - .severity - .unwrap_or(lsp::DiagnosticSeverity::HINT) - .cmp(&b.diag.severity.unwrap_or(lsp::DiagnosticSeverity::HINT)) - }); - let styles = DiagnosticStyles { hint: cx.editor.theme.get("hint"), info: cx.editor.theme.get("info"), @@ -249,11 +238,12 @@ fn diag_picker( ui::PickerColumn::new( "severity", |item: &PickerDiagnostic, styles: &DiagnosticStyles| { + use helix_core::diagnostic::Severity::*; match item.diag.severity { - Some(DiagnosticSeverity::HINT) => Span::styled("HINT", styles.hint), - Some(DiagnosticSeverity::INFORMATION) => Span::styled("INFO", styles.info), - Some(DiagnosticSeverity::WARNING) => Span::styled("WARN", styles.warning), - Some(DiagnosticSeverity::ERROR) => Span::styled("ERROR", styles.error), + Some(Hint) => Span::styled("HINT", styles.hint), + Some(Info) => Span::styled("INFO", styles.info), + Some(Warning) => Span::styled("WARN", styles.warning), + Some(Error) => Span::styled("ERROR", styles.error), _ => Span::raw(""), } .into() @@ -263,11 +253,12 @@ fn diag_picker( item.diag.source.as_deref().unwrap_or("").into() }), ui::PickerColumn::new("code", |item: &PickerDiagnostic, _| { - match item.diag.code.as_ref() { - Some(NumberOrString::Number(n)) => n.to_string().into(), - Some(NumberOrString::String(s)) => s.as_str().into(), - None => "".into(), - } + item.diag + .code + .as_ref() + .map(|c| c.as_string()) + .unwrap_or_default() + .into() }), ui::PickerColumn::new("message", |item: &PickerDiagnostic, _| { item.diag.message.as_str().into() @@ -305,7 +296,7 @@ fn diag_picker( .immediately_show_diagnostic(doc, view.id); }, ) - .with_preview(move |_editor, diag| location_to_file_location(&diag.location)) + .with_preview(|editor, diag| diag.location.file_location(editor)) .truncate_start(false) } @@ -329,8 +320,10 @@ pub fn symbol_picker(cx: &mut Context) { }, location: Location { uri: uri.clone(), - range: symbol.selection_range, - offset_encoding, + range: helix_view::Range::Lsp { + range: symbol.selection_range, + offset_encoding, + }, }, }); for child in symbol.children.into_iter().flatten() { @@ -348,9 +341,7 @@ pub fn symbol_picker(cx: &mut Context) { let request = language_server.document_symbols(doc.identifier()).unwrap(); let offset_encoding = language_server.offset_encoding(); let doc_id = doc.identifier(); - let doc_uri = doc - .uri() - .expect("docs with active language servers must be backed by paths"); + let doc_uri = doc.uri(); async move { let symbols = match request.await? { @@ -365,8 +356,10 @@ pub fn symbol_picker(cx: &mut Context) { .map(|symbol| SymbolInformationItem { location: Location { uri: doc_uri.clone(), - range: symbol.location.range, - offset_encoding, + range: helix_view::Range::Lsp { + range: symbol.location.range, + offset_encoding, + }, }, symbol, }) @@ -433,7 +426,7 @@ pub fn symbol_picker(cx: &mut Context) { jump_to_location(cx.editor, &item.location, action); }, ) - .with_preview(move |_editor, item| location_to_file_location(&item.location)) + .with_preview(|editor, item| item.location.file_location(editor)) .truncate_start(false); compositor.push(Box::new(overlaid(picker))) @@ -490,8 +483,10 @@ pub fn workspace_symbol_picker(cx: &mut Context) { Some(SymbolInformationItem { location: Location { uri, - range: symbol.location.range, - offset_encoding, + range: helix_view::Range::Lsp { + range: symbol.location.range, + offset_encoding, + }, }, symbol, }) @@ -559,7 +554,7 @@ pub fn workspace_symbol_picker(cx: &mut Context) { jump_to_location(cx.editor, &item.location, action); }, ) - .with_preview(|_editor, item| location_to_file_location(&item.location)) + .with_preview(|editor, item| item.location.file_location(editor)) .with_dynamic_query(get_symbols, None) .truncate_start(false); @@ -568,11 +563,10 @@ pub fn workspace_symbol_picker(cx: &mut Context) { pub fn diagnostics_picker(cx: &mut Context) { let doc = doc!(cx.editor); - if let Some(uri) = doc.uri() { - let diagnostics = cx.editor.diagnostics.get(&uri).cloned().unwrap_or_default(); - let picker = diag_picker(cx, [(uri, diagnostics)], DiagnosticsFormat::HideSourcePath); - cx.push_layer(Box::new(overlaid(picker))); - } + let uri = doc.uri(); + let diagnostics = cx.editor.diagnostics.get(&uri).cloned().unwrap_or_default(); + let picker = diag_picker(cx, [(uri, diagnostics)], DiagnosticsFormat::HideSourcePath); + cx.push_layer(Box::new(overlaid(picker))); } pub fn workspace_diagnostics_picker(cx: &mut Context) { @@ -582,249 +576,6 @@ pub fn workspace_diagnostics_picker(cx: &mut Context) { cx.push_layer(Box::new(overlaid(picker))); } -struct CodeActionOrCommandItem { - lsp_item: lsp::CodeActionOrCommand, - language_server_id: LanguageServerId, -} - -impl ui::menu::Item for CodeActionOrCommandItem { - type Data = (); - fn format(&self, _data: &Self::Data) -> Row<'_> { - match &self.lsp_item { - lsp::CodeActionOrCommand::CodeAction(action) => action.title.as_str().into(), - lsp::CodeActionOrCommand::Command(command) => command.title.as_str().into(), - } - } -} - -/// Determines the category of the `CodeAction` using the `CodeAction::kind` field. -/// Returns a number that represent these categories. -/// Categories with a lower number should be displayed first. -/// -/// -/// While the `kind` field is defined as open ended in the LSP spec (any value may be used) -/// in practice a closed set of common values (mostly suggested in the LSP spec) are used. -/// VSCode displays each of these categories separately (separated by a heading in the codeactions picker) -/// to make them easier to navigate. Helix does not display these headings to the user. -/// However it does sort code actions by their categories to achieve the same order as the VScode picker, -/// just without the headings. -/// -/// The order used here is modeled after the [vscode sourcecode](https://github.com/microsoft/vscode/blob/eaec601dd69aeb4abb63b9601a6f44308c8d8c6e/src/vs/editor/contrib/codeAction/browser/codeActionWidget.ts>) -fn action_category(action: &CodeActionOrCommand) -> u32 { - if let CodeActionOrCommand::CodeAction(CodeAction { - kind: Some(kind), .. - }) = action - { - let mut components = kind.as_str().split('.'); - match components.next() { - Some("quickfix") => 0, - Some("refactor") => match components.next() { - Some("extract") => 1, - Some("inline") => 2, - Some("rewrite") => 3, - Some("move") => 4, - Some("surround") => 5, - _ => 7, - }, - Some("source") => 6, - _ => 7, - } - } else { - 7 - } -} - -fn action_preferred(action: &CodeActionOrCommand) -> bool { - matches!( - action, - CodeActionOrCommand::CodeAction(CodeAction { - is_preferred: Some(true), - .. - }) - ) -} - -fn action_fixes_diagnostics(action: &CodeActionOrCommand) -> bool { - matches!( - action, - CodeActionOrCommand::CodeAction(CodeAction { - diagnostics: Some(diagnostics), - .. - }) if !diagnostics.is_empty() - ) -} - -pub fn code_action(cx: &mut Context) { - let (view, doc) = current!(cx.editor); - - let selection_range = doc.selection(view.id).primary(); - - let mut seen_language_servers = HashSet::new(); - - let mut futures: FuturesOrdered<_> = doc - .language_servers_with_feature(LanguageServerFeature::CodeAction) - .filter(|ls| seen_language_servers.insert(ls.id())) - // TODO this should probably already been filtered in something like "language_servers_with_feature" - .filter_map(|language_server| { - let offset_encoding = language_server.offset_encoding(); - let language_server_id = language_server.id(); - let range = range_to_lsp_range(doc.text(), selection_range, offset_encoding); - // Filter and convert overlapping diagnostics - let code_action_context = lsp::CodeActionContext { - diagnostics: doc - .diagnostics() - .iter() - .filter(|&diag| { - selection_range - .overlaps(&helix_core::Range::new(diag.range.start, diag.range.end)) - }) - .map(|diag| diagnostic_to_lsp_diagnostic(doc.text(), diag, offset_encoding)) - .collect(), - only: None, - trigger_kind: Some(CodeActionTriggerKind::INVOKED), - }; - let code_action_request = - language_server.code_actions(doc.identifier(), range, code_action_context)?; - Some((code_action_request, language_server_id)) - }) - .map(|(request, ls_id)| async move { - let Some(mut actions) = request.await? else { - return anyhow::Ok(Vec::new()); - }; - - // remove disabled code actions - actions.retain(|action| { - matches!( - action, - CodeActionOrCommand::Command(_) - | CodeActionOrCommand::CodeAction(CodeAction { disabled: None, .. }) - ) - }); - - // Sort codeactions into a useful order. This behaviour is only partially described in the LSP spec. - // Many details are modeled after vscode because language servers are usually tested against it. - // VScode sorts the codeaction two times: - // - // First the codeactions that fix some diagnostics are moved to the front. - // If both codeactions fix some diagnostics (or both fix none) the codeaction - // that is marked with `is_preferred` is shown first. The codeactions are then shown in separate - // submenus that only contain a certain category (see `action_category`) of actions. - // - // Below this done in in a single sorting step - actions.sort_by(|action1, action2| { - // sort actions by category - let order = action_category(action1).cmp(&action_category(action2)); - if order != Ordering::Equal { - return order; - } - // within the categories sort by relevancy. - // Modeled after the `codeActionsComparator` function in vscode: - // https://github.com/microsoft/vscode/blob/eaec601dd69aeb4abb63b9601a6f44308c8d8c6e/src/vs/editor/contrib/codeAction/browser/codeAction.ts - - // if one code action fixes a diagnostic but the other one doesn't show it first - let order = action_fixes_diagnostics(action1) - .cmp(&action_fixes_diagnostics(action2)) - .reverse(); - if order != Ordering::Equal { - return order; - } - - // if one of the codeactions is marked as preferred show it first - // otherwise keep the original LSP sorting - action_preferred(action1) - .cmp(&action_preferred(action2)) - .reverse() - }); - - Ok(actions - .into_iter() - .map(|lsp_item| CodeActionOrCommandItem { - lsp_item, - language_server_id: ls_id, - }) - .collect()) - }) - .collect(); - - if futures.is_empty() { - cx.editor - .set_error("No configured language server supports code actions"); - return; - } - - cx.jobs.callback(async move { - let mut actions = Vec::new(); - - while let Some(output) = futures.next().await { - match output { - Ok(mut lsp_items) => actions.append(&mut lsp_items), - Err(err) => log::error!("while gathering code actions: {err}"), - } - } - - let call = move |editor: &mut Editor, compositor: &mut Compositor| { - if actions.is_empty() { - editor.set_error("No code actions available"); - return; - } - let mut picker = ui::Menu::new(actions, (), move |editor, action, event| { - if event != PromptEvent::Validate { - return; - } - - // always present here - let action = action.unwrap(); - let Some(language_server) = editor.language_server_by_id(action.language_server_id) - else { - editor.set_error("Language Server disappeared"); - return; - }; - let offset_encoding = language_server.offset_encoding(); - - match &action.lsp_item { - lsp::CodeActionOrCommand::Command(command) => { - log::debug!("code action command: {:?}", command); - editor.execute_lsp_command(command.clone(), action.language_server_id); - } - lsp::CodeActionOrCommand::CodeAction(code_action) => { - log::debug!("code action: {:?}", code_action); - // we support lsp "codeAction/resolve" for `edit` and `command` fields - let mut resolved_code_action = None; - if code_action.edit.is_none() || code_action.command.is_none() { - if let Some(future) = language_server.resolve_code_action(code_action) { - if let Ok(code_action) = helix_lsp::block_on(future) { - resolved_code_action = Some(code_action); - } - } - } - let resolved_code_action = - resolved_code_action.as_ref().unwrap_or(code_action); - - if let Some(ref workspace_edit) = resolved_code_action.edit { - let _ = editor.apply_workspace_edit(offset_encoding, workspace_edit); - } - - // if code action provides both edit and command first the edit - // should be applied and then the command - if let Some(command) = &code_action.command { - editor.execute_lsp_command(command.clone(), action.language_server_id); - } - } - } - }); - picker.move_down(); // pre-select the first item - - let popup = Popup::new("code-action", picker) - .with_scrollbar(false) - .auto_close(true); - - compositor.replace_or_push("code-action", popup); - }; - - Ok(Callback::EditorCompositor(Box::new(call))) - }); -} - #[derive(Debug)] pub struct ApplyEditError { pub kind: ApplyEditErrorKind, @@ -865,20 +616,26 @@ fn goto_impl(editor: &mut Editor, compositor: &mut Compositor, locations: Vec<Lo let columns = [ui::PickerColumn::new( "location", |item: &Location, cwdir: &std::path::PathBuf| { - let path = if let Some(path) = item.uri.as_path() { - path.strip_prefix(cwdir).unwrap_or(path).to_string_lossy() + use std::fmt::Write; + let mut path = if let Some(path) = item.uri.as_path() { + path.strip_prefix(cwdir) + .unwrap_or(path) + .to_string_lossy() + .to_string() } else { - item.uri.to_string().into() + item.uri.to_string() }; - - format!("{path}:{}", item.range.start.line + 1).into() + if let helix_view::Range::Lsp { range, .. } = item.range { + write!(path, ":{}", range.start.line + 1).unwrap(); + } + path.into() }, )]; let picker = Picker::new(columns, 0, locations, cwdir, |cx, location, action| { jump_to_location(cx.editor, location, action) }) - .with_preview(|_editor, location| location_to_file_location(location)); + .with_preview(|editor, location| location.file_location(editor)); compositor.push(Box::new(overlaid(picker))); } } @@ -906,12 +663,14 @@ where match response { Ok((response, offset_encoding)) => match response { Some(lsp::GotoDefinitionResponse::Scalar(lsp_location)) => { - locations.extend(lsp_location_to_location(lsp_location, offset_encoding)); + locations.extend(Location::lsp(lsp_location, offset_encoding)); } Some(lsp::GotoDefinitionResponse::Array(lsp_locations)) => { - locations.extend(lsp_locations.into_iter().flat_map(|location| { - lsp_location_to_location(location, offset_encoding) - })); + locations.extend( + lsp_locations + .into_iter() + .flat_map(|location| Location::lsp(location, offset_encoding)), + ); } Some(lsp::GotoDefinitionResponse::Link(lsp_locations)) => { locations.extend( @@ -923,9 +682,7 @@ where location_link.target_range, ) }) - .flat_map(|location| { - lsp_location_to_location(location, offset_encoding) - }), + .flat_map(|location| Location::lsp(location, offset_encoding)), ); } None => (), @@ -935,13 +692,7 @@ where } let call = move |editor: &mut Editor, compositor: &mut Compositor| { if locations.is_empty() { - editor.set_error(match feature { - LanguageServerFeature::GotoDeclaration => "No declaration found.", - LanguageServerFeature::GotoDefinition => "No definition found.", - LanguageServerFeature::GotoTypeDefinition => "No type definition found.", - LanguageServerFeature::GotoImplementation => "No implementation found.", - _ => "No location found.", - }); + editor.set_error("No definition found."); } else { goto_impl(editor, compositor, locations); } @@ -1011,7 +762,7 @@ pub fn goto_reference(cx: &mut Context) { lsp_locations .into_iter() .flatten() - .flat_map(|location| lsp_location_to_location(location, offset_encoding)), + .flat_map(|location| Location::lsp(location, offset_encoding)), ), Err(err) => log::error!("Error requesting references: {err}"), } @@ -1146,7 +897,7 @@ pub fn rename_symbol(cx: &mut Context) { let Some(language_server) = doc .language_servers_with_feature(LanguageServerFeature::RenameSymbol) - .find(|ls| language_server_id.is_none_or(|id| id == ls.id())) + .find(|ls| language_server_id.map_or(true, |id| id == ls.id())) else { cx.editor .set_error("No configured language server supports symbol renaming"); |