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 ,
} ;
use tokio_stream ::StreamExt ;
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 ,
} ;
use helix_stdx ::path ;
use helix_view ::{
document ::{ DocumentInlayHints , DocumentInlayHintsId } ,
editor ::Action ,
handlers ::lsp ::SignatureHelpInvoked ,
theme ::Style ,
Document , View ,
} ;
use crate ::{
compositor ::{ self , Compositor } ,
job ::Callback ,
ui ::{ self , overlay ::overlaid , FileLocation , Picker , Popup , PromptEvent } ,
} ;
use std ::{
cmp ::Ordering ,
collections ::{ BTreeMap , HashSet } ,
fmt ::Write ,
future ::Future ,
path ::Path ,
} ;
/// 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.
/// Using this macro in a context where the editor automatically queries the LSP
/// (instead of when the user explicitly does so via a keybind like `gd`)
/// will spam the "No configured language server supports \<feature>" status message confusingly.
#[ macro_export ]
macro_rules! language_server_with_feature {
( $editor :expr , $doc :expr , $feature :expr ) = > { {
let language_server = $doc . language_servers_with_feature ( $feature ) . next ( ) ;
match language_server {
Some ( language_server ) = > language_server ,
None = > {
$editor . set_status ( format! (
"No configured language server supports {}" ,
$feature
) ) ;
return ;
}
}
} } ;
}
struct SymbolInformationItem {
symbol : lsp ::SymbolInformation ,
offset_encoding : OffsetEncoding ,
uri : Uri ,
}
struct DiagnosticStyles {
hint : Style ,
info : Style ,
warning : Style ,
error : Style ,
}
struct PickerDiagnostic {
uri : Uri ,
diag : lsp ::Diagnostic ,
offset_encoding : OffsetEncoding ,
}
fn uri_to_file_location < ' a > ( uri : & ' a Uri , range : & lsp ::Range ) -> Option < FileLocation < ' a > > {
let path = uri . as_path ( ) ? ;
let line = Some ( ( range . start . line as usize , range . end . line as usize ) ) ;
Some ( ( path . into ( ) , line ) )
}
fn jump_to_location (
editor : & mut Editor ,
location : & lsp ::Location ,
offset_encoding : OffsetEncoding ,
action : Action ,
) {
let ( view , doc ) = current ! ( editor ) ;
push_jump ( view , doc ) ;
let path = match location . uri . to_file_path ( ) {
Ok ( path ) = > path ,
Err ( _ ) = > {
let err = format! ( "unable to convert URI to filepath: {}" , location . uri ) ;
editor . set_error ( err ) ;
return ;
}
} ;
jump_to_position ( editor , & path , location . range , offset_encoding , action ) ;
}
fn jump_to_position (
editor : & mut Editor ,
path : & Path ,
range : lsp ::Range ,
offset_encoding : OffsetEncoding ,
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 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 ;
} ;
// 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 ) ;
}
}
fn display_symbol_kind ( kind : lsp ::SymbolKind ) -> & ' static str {
match kind {
lsp ::SymbolKind ::FILE = > "file" ,
lsp ::SymbolKind ::MODULE = > "module" ,
lsp ::SymbolKind ::NAMESPACE = > "namespace" ,
lsp ::SymbolKind ::PACKAGE = > "package" ,
lsp ::SymbolKind ::CLASS = > "class" ,
lsp ::SymbolKind ::METHOD = > "method" ,
lsp ::SymbolKind ::PROPERTY = > "property" ,
lsp ::SymbolKind ::FIELD = > "field" ,
lsp ::SymbolKind ::CONSTRUCTOR = > "construct" ,
lsp ::SymbolKind ::ENUM = > "enum" ,
lsp ::SymbolKind ::INTERFACE = > "interface" ,
lsp ::SymbolKind ::FUNCTION = > "function" ,
lsp ::SymbolKind ::VARIABLE = > "variable" ,
lsp ::SymbolKind ::CONSTANT = > "constant" ,
lsp ::SymbolKind ::STRING = > "string" ,
lsp ::SymbolKind ::NUMBER = > "number" ,
lsp ::SymbolKind ::BOOLEAN = > "boolean" ,
lsp ::SymbolKind ::ARRAY = > "array" ,
lsp ::SymbolKind ::OBJECT = > "object" ,
lsp ::SymbolKind ::KEY = > "key" ,
lsp ::SymbolKind ::NULL = > "null" ,
lsp ::SymbolKind ::ENUM_MEMBER = > "enummem" ,
lsp ::SymbolKind ::STRUCT = > "struct" ,
lsp ::SymbolKind ::EVENT = > "event" ,
lsp ::SymbolKind ::OPERATOR = > "operator" ,
lsp ::SymbolKind ::TYPE_PARAMETER = > "typeparam" ,
_ = > {
log ::warn ! ( "Unknown symbol kind: {:?}" , kind ) ;
""
}
}
}
#[ derive(Copy, Clone, PartialEq) ]
enum DiagnosticsFormat {
ShowSourcePath ,
HideSourcePath ,
}
type DiagnosticsPicker = Picker < PickerDiagnostic , DiagnosticStyles > ;
fn diag_picker (
cx : & Context ,
diagnostics : BTreeMap < Uri , Vec < ( lsp ::Diagnostic , LanguageServerId ) > > ,
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 , ls ) in diags {
if let Some ( ls ) = cx . editor . language_server_by_id ( ls ) {
flat_diag . push ( PickerDiagnostic {
uri : uri . clone ( ) ,
diag ,
offset_encoding : ls . offset_encoding ( ) ,
} ) ;
}
}
}
let styles = DiagnosticStyles {
hint : cx . editor . theme . get ( "hint" ) ,
info : cx . editor . theme . get ( "info" ) ,
warning : cx . editor . theme . get ( "warning" ) ,
error : cx . editor . theme . get ( "error" ) ,
} ;
let mut columns = vec! [
ui ::PickerColumn ::new (
"severity" ,
| item : & PickerDiagnostic , styles : & DiagnosticStyles | {
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 ) ,
_ = > Span ::raw ( "" ) ,
}
. 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 ( ) ,
}
} ) ,
ui ::PickerColumn ::new ( "message" , | item : & PickerDiagnostic , _ | {
item . diag . message . as_str ( ) . into ( )
} ) ,
] ;
let mut primary_column = 2 ; // message
if format = = DiagnosticsFormat ::ShowSourcePath {
columns . insert (
// between message code and message
2 ,
ui ::PickerColumn ::new ( "path" , | item : & PickerDiagnostic , _ | {
if let Some ( path ) = item . uri . as_path ( ) {
path ::get_truncated_path ( path )
. to_string_lossy ( )
. to_string ( )
. into ( )
} else {
Default ::default ( )
}
} ) ,
) ;
primary_column + = 1 ;
}
Picker ::new (
columns ,
primary_column ,
flat_diag ,
styles ,
move | cx ,
PickerDiagnostic {
uri ,
diag ,
offset_encoding ,
} ,
action | {
let Some ( path ) = uri . as_path ( ) else {
return ;
} ;
jump_to_position ( cx . editor , path , diag . range , * offset_encoding , action )
} ,
)
. with_preview ( move | _editor , PickerDiagnostic { uri , diag , .. } | {
let line = Some ( ( diag . range . start . line as usize , diag . range . end . line as usize ) ) ;
Some ( ( uri . as_path ( ) ? . into ( ) , line ) )
} )
. truncate_start ( false )
}
pub fn symbol_picker ( cx : & mut Context ) {
fn nested_to_flat (
list : & mut Vec < SymbolInformationItem > ,
file : & lsp ::TextDocumentIdentifier ,
uri : & Uri ,
symbol : lsp ::DocumentSymbol ,
offset_encoding : OffsetEncoding ,
) {
#[ allow(deprecated) ]
list . push ( SymbolInformationItem {
symbol : lsp ::SymbolInformation {
name : symbol . name ,
kind : symbol . kind ,
tags : symbol . tags ,
deprecated : symbol . deprecated ,
location : lsp ::Location ::new ( file . uri . clone ( ) , symbol . selection_range ) ,
container_name : None ,
} ,
offset_encoding ,
uri : uri . clone ( ) ,
} ) ;
for child in symbol . children . into_iter ( ) . flatten ( ) {
nested_to_flat ( list , file , uri , child , offset_encoding ) ;
}
}
let doc = doc ! ( cx . editor ) ;
let mut seen_language_servers = HashSet ::new ( ) ;
let mut futures : FuturesOrdered < _ > = doc
. language_servers_with_feature ( LanguageServerFeature ::DocumentSymbols )
. filter ( | ls | seen_language_servers . insert ( ls . id ( ) ) )
. map ( | language_server | {
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" ) ;
async move {
let json = request . await ? ;
let response : Option < lsp ::DocumentSymbolResponse > = serde_json ::from_value ( json ) ? ;
let symbols = match response {
Some ( symbols ) = > symbols ,
None = > return anyhow ::Ok ( vec! [ ] ) ,
} ;
// lsp has two ways to represent symbols (flat/nested)
// convert the nested variant to flat, so that we have a homogeneous list
let symbols = match symbols {
lsp ::DocumentSymbolResponse ::Flat ( symbols ) = > symbols
. into_iter ( )
. map ( | symbol | SymbolInformationItem {
uri : doc_uri . clone ( ) ,
symbol ,
offset_encoding ,
} )
. collect ( ) ,
lsp ::DocumentSymbolResponse ::Nested ( symbols ) = > {
let mut flat_symbols = Vec ::new ( ) ;
for symbol in symbols {
nested_to_flat (
& mut flat_symbols ,
& doc_id ,
& doc_uri ,
symbol ,
offset_encoding ,
)
}
flat_symbols
}
} ;
Ok ( symbols )
}
} )
. collect ( ) ;
if futures . is_empty ( ) {
cx . editor
. set_error ( "No configured language server supports document symbols" ) ;
return ;
}
cx . jobs . callback ( async move {
let mut symbols = Vec ::new ( ) ;
// TODO if one symbol request errors, all other requests are discarded (even if they're valid)
while let Some ( mut lsp_items ) = futures . try_next ( ) . await ? {
symbols . append ( & mut lsp_items ) ;
}
let call = move | _editor : & mut Editor , compositor : & mut Compositor | {
let columns = [
ui ::PickerColumn ::new ( "kind" , | item : & SymbolInformationItem , _ | {
display_symbol_kind ( item . symbol . kind ) . into ( )
} ) ,
// Some symbols in the document symbol picker may have a URI that isn't
// the current file. It should be rare though, so we concatenate that
// URI in with the symbol name in this picker.
ui ::PickerColumn ::new ( "name" , | item : & SymbolInformationItem , _ | {
item . symbol . name . as_str ( ) . into ( )
} ) ,
] ;
let picker = Picker ::new (
columns ,
1 , // name column
symbols ,
( ) ,
move | cx , item , action | {
jump_to_location (
cx . editor ,
& item . symbol . location ,
item . offset_encoding ,
action ,
) ;
} ,
)
. with_preview ( move | _editor , item | {
uri_to_file_location ( & item . uri , & item . symbol . location . range )
} )
. truncate_start ( false ) ;
compositor . push ( Box ::new ( overlaid ( picker ) ) )
} ;
Ok ( Callback ::EditorCompositor ( Box ::new ( call ) ) )
} ) ;
}
pub fn workspace_symbol_picker ( cx : & mut Context ) {
use crate ::ui ::picker ::Injector ;
let doc = doc ! ( cx . editor ) ;
if doc
. language_servers_with_feature ( LanguageServerFeature ::WorkspaceSymbols )
. count ( )
= = 0
{
cx . editor
. set_error ( "No configured language server supports workspace symbols" ) ;
return ;
}
let get_symbols = | pattern : & str , editor : & mut Editor , _data , injector : & Injector < _ , _ > | {
let doc = doc ! ( editor ) ;
let mut seen_language_servers = HashSet ::new ( ) ;
let mut futures : FuturesOrdered < _ > = doc
. language_servers_with_feature ( LanguageServerFeature ::WorkspaceSymbols )
. filter ( | ls | seen_language_servers . insert ( ls . id ( ) ) )
. map ( | language_server | {
let request = language_server
. workspace_symbols ( pattern . to_string ( ) )
. unwrap ( ) ;
let offset_encoding = language_server . offset_encoding ( ) ;
async move {
let json = request . await ? ;
let response : Vec < _ > =
serde_json ::from_value ::< Option < Vec < lsp ::SymbolInformation > > > ( json ) ?
. unwrap_or_default ( )
. into_iter ( )
. filter_map ( | symbol | {
let uri = match Uri ::try_from ( & symbol . location . uri ) {
Ok ( uri ) = > uri ,
Err ( err ) = > {
log ::warn ! ( "discarding symbol with invalid URI: {err}" ) ;
return None ;
}
} ;
Some ( SymbolInformationItem {
symbol ,
uri ,
offset_encoding ,
} )
} )
. collect ( ) ;
anyhow ::Ok ( response )
}
} )
. collect ( ) ;
if futures . is_empty ( ) {
editor . set_error ( "No configured language server supports workspace symbols" ) ;
}
let injector = injector . clone ( ) ;
async move {
// TODO if one symbol request errors, all other requests are discarded (even if they're valid)
while let Some ( lsp_items ) = futures . try_next ( ) . await ? {
for item in lsp_items {
injector . push ( item ) ? ;
}
}
Ok ( ( ) )
}
. boxed ( )
} ;
let columns = [
ui ::PickerColumn ::new ( "kind" , | item : & SymbolInformationItem , _ | {
display_symbol_kind ( item . symbol . kind ) . into ( )
} ) ,
ui ::PickerColumn ::new ( "name" , | item : & SymbolInformationItem , _ | {
item . symbol . name . as_str ( ) . into ( )
} )
. without_filtering ( ) ,
ui ::PickerColumn ::new ( "path" , | item : & SymbolInformationItem , _ | {
if let Some ( path ) = item . uri . as_path ( ) {
path ::get_relative_path ( path )
. to_string_lossy ( )
. to_string ( )
. into ( )
} else {
item . symbol . location . uri . to_string ( ) . into ( )
}
} ) ,
] ;
let picker = Picker ::new (
columns ,
1 , // name column
[ ] ,
( ) ,
move | cx , item , action | {
jump_to_location (
cx . editor ,
& item . symbol . location ,
item . offset_encoding ,
action ,
) ;
} ,
)
. with_preview ( | _editor , item | uri_to_file_location ( & item . uri , & item . symbol . location . range ) )
. with_dynamic_query ( get_symbols , None )
. truncate_start ( false ) ;
cx . push_layer ( Box ::new ( overlaid ( picker ) ) ) ;
}
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 ) ] . into ( ) ,
DiagnosticsFormat ::HideSourcePath ,
) ;
cx . push_layer ( Box ::new ( overlaid ( picker ) ) ) ;
}
}
pub fn workspace_diagnostics_picker ( cx : & mut Context ) {
// TODO not yet filtered by LanguageServerFeature, need to do something similar as Document::shown_diagnostics here for all open documents
let diagnostics = cx . editor . diagnostics . clone ( ) ;
let picker = diag_picker ( cx , diagnostics , DiagnosticsFormat ::ShowSourcePath ) ;
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 json = request . await ? ;
let response : Option < lsp ::CodeActionResponse > = serde_json ::from_value ( json ) ? ;
let mut actions = match response {
Some ( a ) = > a ,
None = > 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 ( ) ;
// TODO if one code action request errors, all other requests are ignored (even if they're valid)
while let Some ( mut lsp_items ) = futures . try_next ( ) . await ? {
actions . append ( & mut lsp_items ) ;
}
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 ) ;
execute_lsp_command ( editor , action . language_server_id , command . clone ( ) ) ;
}
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 . clone ( ) )
{
if let Ok ( response ) = helix_lsp ::block_on ( future ) {
if let Ok ( code_action ) =
serde_json ::from_value ::< CodeAction > ( response )
{
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 {
execute_lsp_command ( editor , action . language_server_id , command . clone ( ) ) ;
}
}
}
} ) ;
picker . move_down ( ) ; // pre-select the first item
let popup = Popup ::new ( "code-action" , picker ) . with_scrollbar ( false ) ;
compositor . replace_or_push ( "code-action" , popup ) ;
} ;
Ok ( Callback ::EditorCompositor ( Box ::new ( call ) ) )
} ) ;
}
pub fn execute_lsp_command (
editor : & mut Editor ,
language_server_id : LanguageServerId ,
cmd : lsp ::Command ,
) {
// the command is executed on the server and communicated back
// to the client asynchronously using workspace edits
let future = match editor
. language_server_by_id ( language_server_id )
. and_then ( | language_server | 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 ) ;
}
} ) ;
}
#[ derive(Debug) ]
pub struct ApplyEditError {
pub kind : ApplyEditErrorKind ,
pub failed_change_idx : usize ,
}
#[ derive(Debug) ]
pub enum ApplyEditErrorKind {
DocumentChanged ,
FileNotFound ,
UnknownURISchema ,
IoError ( std ::io ::Error ) ,
// TODO: check edits before applying and propagate failure
// InvalidEdit,
}
impl ToString for ApplyEditErrorKind {
fn to_string ( & self ) -> String {
match self {
ApplyEditErrorKind ::DocumentChanged = > "document has changed" . to_string ( ) ,
ApplyEditErrorKind ::FileNotFound = > "file not found" . to_string ( ) ,
ApplyEditErrorKind ::UnknownURISchema = > "URI schema not supported" . to_string ( ) ,
ApplyEditErrorKind ::IoError ( err ) = > err . to_string ( ) ,
}
}
}
/// Precondition: `locations` should be non-empty.
fn goto_impl (
editor : & mut Editor ,
compositor : & mut Compositor ,
locations : Vec < lsp ::Location > ,
offset_encoding : OffsetEncoding ,
) {
let cwdir = helix_stdx ::env ::current_working_dir ( ) ;
match locations . as_slice ( ) {
[ location ] = > {
jump_to_location ( editor , location , offset_encoding , Action ::Replace ) ;
}
[ ] = > unreachable! ( "`locations` should be non-empty for `goto_impl`" ) ,
_locations = > {
let columns = [ ui ::PickerColumn ::new (
"location" ,
| item : & lsp ::Location , cwdir : & std ::path ::PathBuf | {
// The preallocation here will overallocate a few characters since it will account for the
// URL's scheme, which is not used most of the time since that scheme will be "file://".
// Those extra chars will be used to avoid allocating when writing the line number (in the
// common case where it has 5 digits or less, which should be enough for a cast majority
// of usages).
let mut res = String ::with_capacity ( item . uri . as_str ( ) . len ( ) ) ;
if item . uri . scheme ( ) = = "file" {
// With the preallocation above and UTF-8 paths already, this closure will do one (1)
// allocation, for `to_file_path`, else there will be two (2), with `to_string_lossy`.
if let Ok ( path ) = item . uri . to_file_path ( ) {
// We don't convert to a `helix_core::Uri` here because we've already checked the scheme.
// This path won't be normalized but it's only used for display.
res . push_str (
& path . strip_prefix ( cwdir ) . unwrap_or ( & path ) . to_string_lossy ( ) ,
) ;
}
} else {
// Never allocates since we declared the string with this capacity already.
res . push_str ( item . uri . as_str ( ) ) ;
}
// Most commonly, this will not allocate, especially on Unix systems where the root prefix
// is a simple `/` and not `C:\` (with whatever drive letter)
write! ( & mut res , ":{}" , item . range . start . line + 1 )
. expect ( "Will only failed if allocating fail" ) ;
res . into ( )
} ,
) ] ;
let picker = Picker ::new ( columns , 0 , locations , cwdir , move | cx , location , action | {
jump_to_location ( cx . editor , location , offset_encoding , action )
} )
. with_preview ( move | _editor , location | {
use crate ::ui ::picker ::PathOrId ;
let lines = Some ( (
location . range . start . line as usize ,
location . range . end . line as usize ,
) ) ;
// TODO: we should avoid allocating by doing the Uri conversion ahead of time.
//
// To do this, introduce a `Location` type in `helix-core` that reuses the core
// `Uri` type instead of the LSP `Url` type and replaces the LSP `Range` type.
// Refactor the callers of `goto_impl` to pass iterators that translate the
// LSP location type to the custom one in core, or have them collect and pass
// `Vec<Location>`s. Replace the `uri_to_file_location` function with
// `location_to_file_location` that takes only `&helix_core::Location` as
// parameters.
//
// By doing this we can also eliminate the duplicated URI info in the
// `SymbolInformationItem` type and introduce a custom Symbol type in `helix-core`
// which will be reused in the future for tree-sitter based symbol pickers.
let path = Uri ::try_from ( & location . uri ) . ok ( ) ? . as_path_buf ( ) ? ;
#[ allow(deprecated) ]
Some ( ( PathOrId ::from_path_buf ( path ) , lines ) )
} ) ;
compositor . push ( Box ::new ( overlaid ( picker ) ) ) ;
}
}
}
fn to_locations ( definitions : Option < lsp ::GotoDefinitionResponse > ) -> Vec < lsp ::Location > {
match definitions {
Some ( lsp ::GotoDefinitionResponse ::Scalar ( location ) ) = > vec! [ location ] ,
Some ( lsp ::GotoDefinitionResponse ::Array ( locations ) ) = > locations ,
Some ( lsp ::GotoDefinitionResponse ::Link ( locations ) ) = > locations
. into_iter ( )
. map ( | location_link | lsp ::Location {
uri : location_link . target_uri ,
range : location_link . target_range ,
} )
. collect ( ) ,
None = > Vec ::new ( ) ,
}
}
fn goto_single_impl < P , F > ( cx : & mut Context , feature : LanguageServerFeature , request_provider : P )
where
P : Fn ( & Client , lsp ::Position , lsp ::TextDocumentIdentifier ) -> Option < F > ,
F : Future < Output = helix_lsp ::Result < serde_json ::Value > > + ' static + Send ,
{
let ( view , doc ) = current ! ( cx . editor ) ;
let language_server = language_server_with_feature ! ( cx . editor , doc , feature ) ;
let offset_encoding = language_server . offset_encoding ( ) ;
let pos = doc . position ( view . id , offset_encoding ) ;
let future = request_provider ( language_server , pos , doc . identifier ( ) ) . unwrap ( ) ;
cx . callback (
future ,
move | editor , compositor , response : Option < lsp ::GotoDefinitionResponse > | {
let items = to_locations ( response ) ;
if items . is_empty ( ) {
editor . set_error ( "No definition found." ) ;
} else {
goto_impl ( editor , compositor , items , offset_encoding ) ;
}
} ,
) ;
}
pub fn goto_declaration ( cx : & mut Context ) {
goto_single_impl (
cx ,
LanguageServerFeature ::GotoDeclaration ,
| ls , pos , doc_id | ls . goto_declaration ( doc_id , pos , None ) ,
) ;
}
pub fn goto_definition ( cx : & mut Context ) {
goto_single_impl (
cx ,
LanguageServerFeature ::GotoDefinition ,
| ls , pos , doc_id | ls . goto_definition ( doc_id , pos , None ) ,
) ;
}
pub fn goto_type_definition ( cx : & mut Context ) {
goto_single_impl (
cx ,
LanguageServerFeature ::GotoTypeDefinition ,
| ls , pos , doc_id | ls . goto_type_definition ( doc_id , pos , None ) ,
) ;
}
pub fn goto_implementation ( cx : & mut Context ) {
goto_single_impl (
cx ,
LanguageServerFeature ::GotoImplementation ,
| ls , pos , doc_id | ls . goto_implementation ( doc_id , pos , None ) ,
) ;
}
pub fn goto_reference ( cx : & mut Context ) {
let config = cx . editor . config ( ) ;
let ( view , doc ) = current ! ( cx . editor ) ;
// TODO could probably support multiple language servers,
// not sure if there's a real practical use case for this though
let language_server =
language_server_with_feature ! ( cx . editor , doc , LanguageServerFeature ::GotoReference ) ;
let offset_encoding = language_server . offset_encoding ( ) ;
let pos = doc . position ( view . id , offset_encoding ) ;
let future = language_server
. goto_reference (
doc . identifier ( ) ,
pos ,
config . lsp . goto_reference_include_declaration ,
None ,
)
. unwrap ( ) ;
cx . callback (
future ,
move | editor , compositor , response : Option < Vec < lsp ::Location > > | {
let items = response . unwrap_or_default ( ) ;
if items . is_empty ( ) {
editor . set_error ( "No references found." ) ;
} else {
goto_impl ( editor , compositor , items , offset_encoding ) ;
}
} ,
) ;
}
pub fn signature_help ( cx : & mut Context ) {
cx . editor
. handlers
. trigger_signature_help ( SignatureHelpInvoked ::Manual , cx . editor )
}
pub fn hover ( cx : & mut Context ) {
let ( view , doc ) = current ! ( cx . editor ) ;
// TODO support multiple language servers (merge UI somehow)
let language_server =
language_server_with_feature ! ( cx . editor , doc , LanguageServerFeature ::Hover ) ;
// TODO: factor out a doc.position_identifier() that returns lsp::TextDocumentPositionIdentifier
let pos = doc . position ( view . id , language_server . offset_encoding ( ) ) ;
let future = language_server
. text_document_hover ( doc . identifier ( ) , pos , None )
. unwrap ( ) ;
cx . callback (
future ,
move | editor , compositor , response : Option < lsp ::Hover > | {
if let Some ( hover ) = response {
// hover.contents / .range <- used for visualizing
fn marked_string_to_markdown ( contents : lsp ::MarkedString ) -> String {
match contents {
lsp ::MarkedString ::String ( contents ) = > contents ,
lsp ::MarkedString ::LanguageString ( string ) = > {
if string . language = = "markdown" {
string . value
} else {
format! ( "```{}\n{}\n```" , string . language , string . value )
}
}
}
}
let contents = match hover . contents {
lsp ::HoverContents ::Scalar ( contents ) = > marked_string_to_markdown ( contents ) ,
lsp ::HoverContents ::Array ( contents ) = > contents
. into_iter ( )
. map ( marked_string_to_markdown )
. collect ::< Vec < _ > > ( )
. join ( "\n\n" ) ,
lsp ::HoverContents ::Markup ( contents ) = > contents . value ,
} ;
// skip if contents empty
let contents = ui ::Markdown ::new ( contents , editor . syn_loader . clone ( ) ) ;
let popup = Popup ::new ( "hover" , contents ) . auto_close ( true ) ;
compositor . replace_or_push ( "hover" , popup ) ;
}
} ,
) ;
}
pub fn rename_symbol ( cx : & mut Context ) {
fn get_prefill_from_word_boundary ( editor : & Editor ) -> String {
let ( view , doc ) = current_ref ! ( editor ) ;
let text = doc . text ( ) . slice ( .. ) ;
let primary_selection = doc . selection ( view . id ) . primary ( ) ;
if primary_selection . len ( ) > 1 {
primary_selection
} else {
use helix_core ::textobject ::{ textobject_word , TextObject } ;
textobject_word ( text , primary_selection , TextObject ::Inside , 1 , false )
}
. fragment ( text )
. into ( )
}
fn get_prefill_from_lsp_response (
editor : & Editor ,
offset_encoding : OffsetEncoding ,
response : Option < lsp ::PrepareRenameResponse > ,
) -> Result < String , & ' static str > {
match response {
Some ( lsp ::PrepareRenameResponse ::Range ( range ) ) = > {
let text = doc ! ( editor ) . text ( ) ;
Ok ( lsp_range_to_range ( text , range , offset_encoding )
. ok_or ( "lsp sent invalid selection range for rename" ) ?
. fragment ( text . slice ( .. ) )
. into ( ) )
}
Some ( lsp ::PrepareRenameResponse ::RangeWithPlaceholder { placeholder , .. } ) = > {
Ok ( placeholder )
}
Some ( lsp ::PrepareRenameResponse ::DefaultBehavior { .. } ) = > {
Ok ( get_prefill_from_word_boundary ( editor ) )
}
None = > Err ( "lsp did not respond to prepare rename request" ) ,
}
}
fn create_rename_prompt (
editor : & Editor ,
prefill : String ,
history_register : Option < char > ,
language_server_id : Option < LanguageServerId > ,
) -> Box < ui ::Prompt > {
let prompt = ui ::Prompt ::new (
"rename-to:" . into ( ) ,
history_register ,
ui ::completers ::none ,
move | cx : & mut compositor ::Context , input : & str , event : PromptEvent | {
if event ! = PromptEvent ::Validate {
return ;
}
let ( view , doc ) = current ! ( cx . editor ) ;
let Some ( language_server ) = doc
. language_servers_with_feature ( LanguageServerFeature ::RenameSymbol )
. find ( | ls | language_server_id . map_or ( true , | id | id = = ls . id ( ) ) )
else {
cx . editor
. set_error ( "No configured language server supports symbol renaming" ) ;
return ;
} ;
let offset_encoding = language_server . offset_encoding ( ) ;
let pos = doc . position ( view . id , offset_encoding ) ;
let future = language_server
. rename_symbol ( doc . identifier ( ) , pos , input . to_string ( ) )
. unwrap ( ) ;
match block_on ( future ) {
Ok ( edits ) = > {
let _ = cx . editor . apply_workspace_edit ( offset_encoding , & edits ) ;
}
Err ( err ) = > cx . editor . set_error ( err . to_string ( ) ) ,
}
} ,
)
. with_line ( prefill , editor ) ;
Box ::new ( prompt )
}
let ( view , doc ) = current_ref ! ( cx . editor ) ;
let history_register = cx . register ;
if doc
. language_servers_with_feature ( LanguageServerFeature ::RenameSymbol )
. next ( )
. is_none ( )
{
cx . editor
. set_error ( "No configured language server supports symbol renaming" ) ;
return ;
}
let language_server_with_prepare_rename_support = doc
. language_servers_with_feature ( LanguageServerFeature ::RenameSymbol )
. find ( | ls | {
matches! (
ls . capabilities ( ) . rename_provider ,
Some ( lsp ::OneOf ::Right ( lsp ::RenameOptions {
prepare_provider : Some ( true ) ,
..
} ) )
)
} ) ;
if let Some ( language_server ) = language_server_with_prepare_rename_support {
let ls_id = language_server . id ( ) ;
let offset_encoding = language_server . offset_encoding ( ) ;
let pos = doc . position ( view . id , offset_encoding ) ;
let future = language_server
. prepare_rename ( doc . identifier ( ) , pos )
. unwrap ( ) ;
cx . callback (
future ,
move | editor , compositor , response : Option < lsp ::PrepareRenameResponse > | {
let prefill = match get_prefill_from_lsp_response ( editor , offset_encoding , response )
{
Ok ( p ) = > p ,
Err ( e ) = > {
editor . set_error ( e ) ;
return ;
}
} ;
let prompt = create_rename_prompt ( editor , prefill , history_register , Some ( ls_id ) ) ;
compositor . push ( prompt ) ;
} ,
) ;
} else {
let prefill = get_prefill_from_word_boundary ( cx . editor ) ;
let prompt = create_rename_prompt ( cx . editor , prefill , history_register , None ) ;
cx . push_layer ( prompt ) ;
}
}
pub fn select_references_to_symbol_under_cursor ( cx : & mut Context ) {
let ( view , doc ) = current ! ( cx . editor ) ;
let language_server =
language_server_with_feature ! ( cx . editor , doc , LanguageServerFeature ::DocumentHighlight ) ;
let offset_encoding = language_server . offset_encoding ( ) ;
let pos = doc . position ( view . id , offset_encoding ) ;
let future = language_server
. text_document_document_highlight ( doc . identifier ( ) , pos , None )
. unwrap ( ) ;
cx . callback (
future ,
move | editor , _compositor , response : Option < Vec < lsp ::DocumentHighlight > > | {
let document_highlights = match response {
Some ( highlights ) if ! highlights . is_empty ( ) = > highlights ,
_ = > return ,
} ;
let ( view , doc ) = current ! ( editor ) ;
let text = doc . text ( ) ;
let pos = doc . selection ( view . id ) . primary ( ) . cursor ( text . slice ( .. ) ) ;
// We must find the range that contains our primary cursor to prevent our primary cursor to move
let mut primary_index = 0 ;
let ranges = document_highlights
. iter ( )
. filter_map ( | highlight | lsp_range_to_range ( text , highlight . range , offset_encoding ) )
. enumerate ( )
. map ( | ( i , range ) | {
if range . contains ( pos ) {
primary_index = i ;
}
range
} )
. collect ( ) ;
let selection = Selection ::new ( ranges , primary_index ) ;
doc . set_selection ( view . id , selection ) ;
} ,
) ;
}
pub fn compute_inlay_hints_for_all_views ( editor : & mut Editor , jobs : & mut crate ::job ::Jobs ) {
if ! editor . config ( ) . lsp . display_inlay_hints {
return ;
}
for ( view , _ ) in editor . tree . views ( ) {
let doc = match editor . documents . get ( & view . doc ) {
Some ( doc ) = > doc ,
None = > continue ,
} ;
if let Some ( callback ) = compute_inlay_hints_for_view ( view , doc ) {
jobs . callback ( callback ) ;
}
}
}
fn compute_inlay_hints_for_view (
view : & View ,
doc : & Document ,
) -> Option < std ::pin ::Pin < Box < impl Future < Output = Result < crate ::job ::Callback , anyhow ::Error > > > > > {
let view_id = view . id ;
let doc_id = view . doc ;
let language_server = doc
. language_servers_with_feature ( LanguageServerFeature ::InlayHints )
. next ( ) ? ;
let doc_text = doc . text ( ) ;
let len_lines = doc_text . len_lines ( ) ;
// Compute ~3 times the current view height of inlay hints, that way some scrolling
// will not show half the view with hints and half without while still being faster
// than computing all the hints for the full file (which could be dozens of time
// longer than the view is).
let view_height = view . inner_height ( ) ;
let first_visible_line = doc_text . char_to_line ( view . offset . anchor . min ( doc_text . len_chars ( ) ) ) ;
let first_line = first_visible_line . saturating_sub ( view_height ) ;
let last_line = first_visible_line
. saturating_add ( view_height . saturating_mul ( 2 ) )
. min ( len_lines ) ;
let new_doc_inlay_hints_id = DocumentInlayHintsId {
first_line ,
last_line ,
} ;
// Don't recompute the annotations in case nothing has changed about the view
if ! doc . inlay_hints_oudated
& & doc
. inlay_hints ( view_id )
. map_or ( false , | dih | dih . id = = new_doc_inlay_hints_id )
{
return None ;
}
let doc_slice = doc_text . slice ( .. ) ;
let first_char_in_range = doc_slice . line_to_char ( first_line ) ;
let last_char_in_range = doc_slice . line_to_char ( last_line ) ;
let range = helix_lsp ::util ::range_to_lsp_range (
doc_text ,
helix_core ::Range ::new ( first_char_in_range , last_char_in_range ) ,
language_server . offset_encoding ( ) ,
) ;
let offset_encoding = language_server . offset_encoding ( ) ;
let callback = super ::make_job_callback (
language_server . text_document_range_inlay_hints ( doc . identifier ( ) , range , None ) ? ,
move | editor , _compositor , response : Option < Vec < lsp ::InlayHint > > | {
// The config was modified or the window was closed while the request was in flight
if ! editor . config ( ) . lsp . display_inlay_hints | | editor . tree . try_get ( view_id ) . is_none ( ) {
return ;
}
// Add annotations to relevant document, not the current one (it may have changed in between)
let doc = match editor . documents . get_mut ( & doc_id ) {
Some ( doc ) = > doc ,
None = > return ,
} ;
// If we have neither hints nor an LSP, empty the inlay hints since they're now oudated
let mut hints = match response {
Some ( hints ) if ! hints . is_empty ( ) = > hints ,
_ = > {
doc . set_inlay_hints (
view_id ,
DocumentInlayHints ::empty_with_id ( new_doc_inlay_hints_id ) ,
) ;
doc . inlay_hints_oudated = false ;
return ;
}
} ;
// Most language servers will already send them sorted but ensure this is the case to
// avoid errors on our end.
hints . sort_unstable_by_key ( | inlay_hint | inlay_hint . position ) ;
let mut padding_before_inlay_hints = Vec ::new ( ) ;
let mut type_inlay_hints = Vec ::new ( ) ;
let mut parameter_inlay_hints = Vec ::new ( ) ;
let mut other_inlay_hints = Vec ::new ( ) ;
let mut padding_after_inlay_hints = Vec ::new ( ) ;
let doc_text = doc . text ( ) ;
for hint in hints {
let char_idx =
match helix_lsp ::util ::lsp_pos_to_pos ( doc_text , hint . position , offset_encoding )
{
Some ( pos ) = > pos ,
// Skip inlay hints that have no "real" position
None = > continue ,
} ;
let label = match hint . label {
lsp ::InlayHintLabel ::String ( s ) = > s ,
lsp ::InlayHintLabel ::LabelParts ( parts ) = > parts
. into_iter ( )
. map ( | p | p . value )
. collect ::< Vec < _ > > ( )
. join ( "" ) ,
} ;
let inlay_hints_vec = match hint . kind {
Some ( lsp ::InlayHintKind ::TYPE ) = > & mut type_inlay_hints ,
Some ( lsp ::InlayHintKind ::PARAMETER ) = > & mut parameter_inlay_hints ,
// We can't warn on unknown kind here since LSPs are free to set it or not, for
// example Rust Analyzer does not: every kind will be `None`.
_ = > & mut other_inlay_hints ,
} ;
if let Some ( true ) = hint . padding_left {
padding_before_inlay_hints . push ( InlineAnnotation ::new ( char_idx , " " ) ) ;
}
inlay_hints_vec . push ( InlineAnnotation ::new ( char_idx , label ) ) ;
if let Some ( true ) = hint . padding_right {
padding_after_inlay_hints . push ( InlineAnnotation ::new ( char_idx , " " ) ) ;
}
}
doc . set_inlay_hints (
view_id ,
DocumentInlayHints {
id : new_doc_inlay_hints_id ,
type_inlay_hints ,
parameter_inlay_hints ,
other_inlay_hints ,
padding_before_inlay_hints ,
padding_after_inlay_hints ,
} ,
) ;
doc . inlay_hints_oudated = false ;
} ,
) ;
Some ( callback )
}