3 # Functions related to handling LSP diagnostics.
5 import './options.vim' as opt
6 import './buffer.vim' as buf
10 # serverDiagnostics: {
11 # lspServer1Id: [diag, diag, diag]
12 # lspServer2Id: [diag, diag, diag]
14 # serverDiagnosticsByLnum: {
15 # lspServer1Id: { [lnum]: [diag, diag diag] },
16 # lspServer2Id: { [lnum]: [diag, diag diag] },
18 # sortedDiagnostics: [lspServer1.diags, ...lspServer2.diags]->sort()
20 var diagsMap: dict<dict<any>> = {}
22 # Initialize the signs and the text property type used for diagnostics.
24 # Signs and their highlight groups used for LSP diagnostics
26 {name: 'LspDiagLine', default: true, linksto: 'DiffAdd'},
27 {name: 'LspDiagSignErrorText', default: true, linksto: 'ErrorMsg'},
28 {name: 'LspDiagSignWarningText', default: true, linksto: 'Search'},
29 {name: 'LspDiagSignInfoText', default: true, linksto: 'Pmenu'},
30 {name: 'LspDiagSignHintText', default: true, linksto: 'Question'}
35 text: opt.lspOptions.diagSignErrorText,
36 texthl: 'LspDiagSignErrorText',
40 name: 'LspDiagWarning',
41 text: opt.lspOptions.diagSignWarningText,
42 texthl: 'LspDiagSignWarningText',
47 text: opt.lspOptions.diagSignInfoText,
48 texthl: 'LspDiagSignInfoText',
53 text: opt.lspOptions.diagSignHintText,
54 texthl: 'LspDiagSignHintText',
59 # Diag inline highlight groups and text property types
61 {name: 'LspDiagInlineError', default: true, linksto: 'SpellBad'},
62 {name: 'LspDiagInlineWarning', default: true, linksto: 'SpellCap'},
63 {name: 'LspDiagInlineInfo', default: true, linksto: 'SpellRare'},
64 {name: 'LspDiagInlineHint', default: true, linksto: 'SpellLocal'}
67 var override = &cursorline
68 && &cursorlineopt =~ '\<line\>\|\<screenline\>\|\<both\>'
70 prop_type_add('LspDiagInlineError',
71 {highlight: 'LspDiagInlineError',
74 prop_type_add('LspDiagInlineWarning',
75 {highlight: 'LspDiagInlineWarning',
78 prop_type_add('LspDiagInlineInfo',
79 {highlight: 'LspDiagInlineInfo',
82 prop_type_add('LspDiagInlineHint',
83 {highlight: 'LspDiagInlineHint',
87 # Diag virtual text highlight groups and text property types
89 {name: 'LspDiagVirtualTextError', default: true, linksto: 'SpellBad'},
90 {name: 'LspDiagVirtualTextWarning', default: true, linksto: 'SpellCap'},
91 {name: 'LspDiagVirtualTextInfo', default: true, linksto: 'SpellRare'},
92 {name: 'LspDiagVirtualTextHint', default: true, linksto: 'SpellLocal'},
94 prop_type_add('LspDiagVirtualTextError',
95 {highlight: 'LspDiagVirtualTextError', override: true})
96 prop_type_add('LspDiagVirtualTextWarning',
97 {highlight: 'LspDiagVirtualTextWarning', override: true})
98 prop_type_add('LspDiagVirtualTextInfo',
99 {highlight: 'LspDiagVirtualTextInfo', override: true})
100 prop_type_add('LspDiagVirtualTextHint',
101 {highlight: 'LspDiagVirtualTextHint', override: true})
103 if opt.lspOptions.aleSupport
108 pattern: 'ALEWantResults',
109 cmd: 'AleHook(g:ale_want_results_buffer)'
115 # Sort diagnostics ascending based on line and character offset
116 def SortDiags(diags: list<dict<any>>): list<dict<any>>
117 return diags->sort((a, b) => {
118 var a_start = a.range.start
119 var b_start = b.range.start
120 var linediff = a_start.line - b_start.line
122 return a_start.character - b_start.character
128 # Remove the diagnostics stored for buffer "bnr"
129 export def DiagRemoveFile(bnr: number)
130 if diagsMap->has_key(bnr)
131 diagsMap->remove(bnr)
135 def DiagSevToSignName(severity: number): string
136 var typeMap: list<string> = ['LspDiagError', 'LspDiagWarning',
137 'LspDiagInfo', 'LspDiagHint']
141 return typeMap[severity - 1]
144 def DiagSevToInlineHLName(severity: number): string
145 var typeMap: list<string> = [
146 'LspDiagInlineError',
147 'LspDiagInlineWarning',
152 return 'LspDiagInlineHint'
154 return typeMap[severity - 1]
157 def DiagSevToVirtualTextHLName(severity: number): string
158 var typeMap: list<string> = [
159 'LspDiagVirtualTextError',
160 'LspDiagVirtualTextWarning',
161 'LspDiagVirtualTextInfo',
162 'LspDiagVirtualTextHint'
165 return 'LspDiagVirtualTextHint'
167 return typeMap[severity - 1]
170 def DiagSevToSymbolText(severity: number): string
171 var lspOpts = opt.lspOptions
172 var typeMap: list<string> = [
173 lspOpts.diagSignErrorText,
174 lspOpts.diagSignWarningText,
175 lspOpts.diagSignInfoText,
176 lspOpts.diagSignHintText
179 return lspOpts.diagSignHintText
181 return typeMap[severity - 1]
184 # Remove signs and text properties for diagnostics in buffer
185 def RemoveDiagVisualsForBuffer(bnr: number)
186 var lspOpts = opt.lspOptions
187 if lspOpts.showDiagWithSign
188 # Remove all the existing diagnostic signs
189 sign_unplace('LSPDiag', {buffer: bnr})
192 if lspOpts.showDiagWithVirtualText
193 # Remove all the existing virtual text
194 prop_remove({type: 'LspDiagVirtualTextError', bufnr: bnr, all: true})
195 prop_remove({type: 'LspDiagVirtualTextWarning', bufnr: bnr, all: true})
196 prop_remove({type: 'LspDiagVirtualTextInfo', bufnr: bnr, all: true})
197 prop_remove({type: 'LspDiagVirtualTextHint', bufnr: bnr, all: true})
200 if lspOpts.highlightDiagInline
201 # Remove all the existing virtual text
202 prop_remove({type: 'LspDiagInlineError', bufnr: bnr, all: true})
203 prop_remove({type: 'LspDiagInlineWarning', bufnr: bnr, all: true})
204 prop_remove({type: 'LspDiagInlineInfo', bufnr: bnr, all: true})
205 prop_remove({type: 'LspDiagInlineHint', bufnr: bnr, all: true})
209 # Refresh the placed diagnostics in buffer "bnr"
210 # This inline signs, inline props, and virtual text diagnostics
211 def DiagsRefresh(bnr: number)
212 :silent! bnr->bufload()
214 RemoveDiagVisualsForBuffer(bnr)
216 if !diagsMap->has_key(bnr) ||
217 diagsMap[bnr].sortedDiagnostics->empty()
221 # Initialize default/fallback properties for diagnostic virtual text:
222 var diag_align: string = 'above'
223 var diag_wrap: string = 'truncate'
224 var diag_symbol: string = '┌─'
225 var lspOpts = opt.lspOptions
227 if lspOpts.diagVirtualTextAlign == 'below'
229 diag_wrap = 'truncate'
231 elseif lspOpts.diagVirtualTextAlign == 'after'
237 var signs: list<dict<any>> = []
238 var diags: list<dict<any>> = diagsMap[bnr].sortedDiagnostics
240 # TODO: prioritize most important severity if there are multiple
241 # diagnostics from the same line
242 var d_range = diag.range
243 var d_start = d_range.start
244 var d_end = d_range.end
245 var lnum = d_start.line + 1
246 if lspOpts.showDiagWithSign
247 signs->add({id: 0, buffer: bnr, group: 'LSPDiag',
248 lnum: lnum, name: DiagSevToSignName(diag.severity),
249 priority: 10 - diag.severity})
253 if lspOpts.highlightDiagInline
254 prop_add(lnum, util.GetLineByteFromPos(bnr, d_start) + 1,
255 {end_lnum: d_end.line + 1,
256 end_col: util.GetLineByteFromPos(bnr, d_end) + 1,
258 type: DiagSevToInlineHLName(diag.severity)})
261 if lspOpts.showDiagWithVirtualText
264 var symbol: string = diag_symbol
266 if diag_align == 'after'
268 symbol = DiagSevToSymbolText(diag.severity)
270 var charIdx = util.GetCharIdxWithoutCompChar(bnr, d_start)
273 padding = strdisplaywidth(getline(lnum)[ : charIdx - 1])
277 prop_add(lnum, 0, {bufnr: bnr,
278 type: DiagSevToVirtualTextHLName(diag.severity),
279 text: $'{symbol} {diag.message}',
280 text_align: diag_align,
281 text_wrap: diag_wrap,
282 text_padding_left: padding})
284 catch /E966\|E964/ # Invalid lnum | Invalid col
285 # Diagnostics arrive asynchronous and the document changed while they
286 # wore send. Ignore this as new once will arrive shortly.
290 if lspOpts.showDiagWithSign
291 signs->sign_placelist()
295 # Sends diagnostics to Ale
296 def SendAleDiags(bnr: number, timerid: number)
297 if !diagsMap->has_key(bnr)
301 # Convert to Ale's diagnostics format (:h ale-loclist-format)
302 ale#other_source#ShowResults(bnr, 'lsp',
303 diagsMap[bnr].sortedDiagnostics->mapnew((_, v) => {
304 return {text: v.message,
305 lnum: v.range.start.line + 1,
306 col: util.GetLineByteFromPos(bnr, v.range.start) + 1,
307 end_lnum: v.range.end.line + 1,
308 end_col: util.GetLineByteFromPos(bnr, v.range.end) + 1,
309 type: "EWIH"[v.severity - 1]}
314 # Hook called when Ale wants to retrieve new diagnostics
315 def AleHook(bnr: number)
316 ale#other_source#StartChecking(bnr, 'lsp')
317 timer_start(0, function('SendAleDiags', [bnr]))
320 # New LSP diagnostic messages received from the server for a file.
321 # Update the signs placed in the buffer for this file
322 export def ProcessNewDiags(bnr: number)
323 DiagsUpdateLocList(bnr)
325 var lspOpts = opt.lspOptions
326 if lspOpts.aleSupport
327 SendAleDiags(bnr, -1)
329 elseif !lspOpts.autoHighlightDiags
333 if bnr == -1 || !diagsMap->has_key(bnr)
337 var curmode: string = mode()
338 if curmode == 'i' || curmode == 'R' || curmode == 'Rv'
339 # postpone placing signs in insert mode and replace mode. These will be
340 # placed after the user returns to Normal mode.
341 b:LspDiagsUpdatePending = true
348 # process a diagnostic notification message from the LSP server
349 # Notification: textDocument/publishDiagnostics
350 # Param: PublishDiagnosticsParams
351 export def DiagNotification(lspserver: dict<any>, uri: string, diags_arg: list<dict<any>>): void
352 # Diagnostics are disabled for this server?
353 var diagSupported = lspserver.features->get('diagnostics', true)
358 var fname: string = util.LspUriToFile(uri)
359 var bnr: number = fname->bufnr()
361 # Is this condition possible?
365 var newDiags: list<dict<any>> = diags_arg
367 if lspserver.needOffsetEncoding
368 # Decode the position encoding in all the diags
369 newDiags->map((_, dval) => {
370 lspserver.decodeRange(bnr, dval.range)
375 if lspserver.processDiagHandler != null_function
376 newDiags = lspserver.processDiagHandler(diags_arg)
379 # TODO: Is the buffer (bnr) always a loaded buffer? Should we load it here?
380 var lastlnum: number = bnr->getbufinfo()[0].linecount
382 # store the diagnostic for each line separately
383 var diagsByLnum: dict<list<dict<any>>> = {}
385 var diagWithinRange: list<dict<any>> = []
387 var d_start = diag.range.start
388 if d_start.line + 1 > lastlnum
389 # Make sure the line number is a valid buffer line number
390 d_start.line = lastlnum - 1
393 var lnum = d_start.line + 1
394 if !diagsByLnum->has_key(lnum)
395 diagsByLnum[lnum] = []
397 diagsByLnum[lnum]->add(diag)
399 diagWithinRange->add(diag)
402 var serverDiags: dict<list<any>> = diagsMap->has_key(bnr) ?
403 diagsMap[bnr].serverDiagnostics : {}
404 serverDiags[lspserver.id] = diagWithinRange
406 var serverDiagsByLnum: dict<dict<list<any>>> = diagsMap->has_key(bnr) ?
407 diagsMap[bnr].serverDiagnosticsByLnum : {}
408 serverDiagsByLnum[lspserver.id] = diagsByLnum
410 # store the diagnostic for each line separately
411 var joinedServerDiags: list<dict<any>> = []
412 for diags in serverDiags->values()
414 joinedServerDiags->add(diag)
418 var sortedDiags = SortDiags(joinedServerDiags)
421 sortedDiagnostics: sortedDiags,
422 serverDiagnosticsByLnum: serverDiagsByLnum,
423 serverDiagnostics: serverDiags
428 # Notify user scripts that diags has been updated
429 if exists('#User#LspDiagsUpdated')
430 :doautocmd <nomodeline> User LspDiagsUpdated
434 # get the count of error in the current buffer
435 export def DiagsGetErrorCount(): dict<number>
441 var bnr: number = bufnr()
442 if diagsMap->has_key(bnr)
443 var diags = diagsMap[bnr].sortedDiagnostics
445 var severity = diag->get('severity', -1)
458 return {Error: errCount, Warn: warnCount, Info: infoCount, Hint: hintCount}
461 # Map the LSP DiagnosticSeverity to a quickfix type character
462 def DiagSevToQfType(severity: number): string
463 var typeMap: list<string> = ['E', 'W', 'I', 'N']
469 return typeMap[severity - 1]
472 # Update the location list window for the current window with the diagnostic
474 # Returns true if diagnostics is not empty and false if it is empty.
475 def DiagsUpdateLocList(bnr: number, calledByCmd: bool = false): bool
476 var fname: string = bnr->bufname()->fnamemodify(':p')
481 var LspQfId: number = bnr->getbufvar('LspQfId', 0)
482 if LspQfId == 0 && !opt.lspOptions.autoPopulateDiags && !calledByCmd
483 # Diags location list is not present. Create the location list only if
484 # the 'autoPopulateDiags' option is set or the :LspDiagShow command is
489 if LspQfId != 0 && getloclist(0, {id: LspQfId}).id != LspQfId
490 # Previously used location list for the diagnostics is gone
494 if !diagsMap->has_key(bnr) ||
495 diagsMap[bnr].sortedDiagnostics->empty()
497 setloclist(0, [], 'r', {id: LspQfId, items: []})
502 var qflist: list<dict<any>> = []
505 var diags = diagsMap[bnr].sortedDiagnostics
507 var d_range = diag.range
508 var d_start = d_range.start
509 var d_end = d_range.end
510 text = diag.message->substitute("\n\\+", "\n", 'g')
511 qflist->add({filename: fname,
512 lnum: d_start.line + 1,
513 col: util.GetLineByteFromPos(bnr, d_start) + 1,
514 end_lnum: d_end.line + 1,
515 end_col: util.GetLineByteFromPos(bnr, d_end) + 1,
517 type: DiagSevToQfType(diag.severity)})
521 var props = {title: 'Language Server Diagnostics', items: qflist}
526 setloclist(0, [], op, props)
528 setbufvar(bnr, 'LspQfId', getloclist(0, {id: 0}).id)
534 # Display the diagnostic messages from the LSP server for the current buffer
536 export def ShowAllDiags(): void
537 if !DiagsUpdateLocList(bufnr(), true)
538 util.WarnMsg($'No diagnostic messages found for {@%}')
542 var save_winid = win_getid()
543 # make the diagnostics error list the active one and open it
544 var LspQfId: number = getbufvar(bufnr(), 'LspQfId', 0)
545 var LspQfNr: number = getloclist(0, {id: LspQfId, nr: 0}).nr
546 exe $':{LspQfNr} lhistory'
548 if !opt.lspOptions.keepFocusInDiags
549 save_winid->win_gotoid()
553 # Display the message of "diag" in a popup window right below the position in
554 # the diagnostic message.
555 def ShowDiagInPopup(diag: dict<any>)
556 var d_start = diag.range.start
557 var dlnum = d_start.line + 1
558 var ltext = dlnum->getline()
559 var dlcol = ltext->byteidxcomp(d_start.character) + 1
561 var lastline = line('$')
563 # The line number is outside the last line in the file.
567 # The column is outside the last character in line.
568 dlcol = ltext->len() + 1
570 var d = screenpos(0, dlnum, dlcol)
572 # If the diag position cannot be converted to Vim lnum/col, then use
573 # the current cursor position
574 d = {row: line('.'), col: col('.')}
577 # Display a popup right below the diagnostics position
578 var msg = diag.message->split("\n")
579 var msglen = msg->reduce((acc, val) => max([acc, val->strcharlen()]), 0)
582 ppopts.pos = 'topleft'
583 ppopts.line = d.row + 1
594 popup_create(msg, ppopts)
597 # Display the "diag" message in a popup or in the status message area
598 def DisplayDiag(diag: dict<any>)
599 if opt.lspOptions.showDiagInPopup
600 # Display the diagnostic message in a popup window.
601 ShowDiagInPopup(diag)
603 # Display the diagnostic message in the status message area
608 # Show the diagnostic message for the current line
609 export def ShowCurrentDiag(atPos: bool)
610 var bnr: number = bufnr()
611 var lnum: number = line('.')
612 var col: number = charcol('.')
613 var diag: dict<any> = GetDiagByPos(bnr, lnum, col, atPos)
615 util.WarnMsg($'No diagnostic messages found for current {atPos ? "position" : "line"}')
621 # Show the diagnostic message for the current line without linebreak
622 export def ShowCurrentDiagInStatusLine()
623 var bnr: number = bufnr()
624 var lnum: number = line('.')
625 var col: number = charcol('.')
626 var diag: dict<any> = GetDiagByPos(bnr, lnum, col)
628 # 15 is a enough length not to cause line break
629 var max_width = &columns - 15
631 if diag->has_key('code')
632 code = $'[{diag.code}] '
634 var msgNoLineBreak = code .. substitute(substitute(diag.message, "\n", ' ', ''), "\\n", ' ', '')
635 :echo msgNoLineBreak[ : max_width]
641 # Get the diagnostic from the LSP server for a particular line and character
643 export def GetDiagByPos(bnr: number, lnum: number, col: number,
644 atPos: bool = false): dict<any>
645 var diags_in_line = GetDiagsByLine(bnr, lnum)
647 for diag in diags_in_line
649 var startCharIdx = util.GetCharIdxWithoutCompChar(bnr, r.start)
650 var endCharIdx = util.GetCharIdxWithoutCompChar(bnr, r.end)
652 if col >= startCharIdx + 1 && col < endCharIdx + 1
655 elseif col <= startCharIdx + 1
660 # No diagnostic to the right of the position, return the last one instead
661 if !atPos && diags_in_line->len() > 0
662 return diags_in_line[-1]
668 # Get all diagnostics from the LSP server for a particular line in a file
669 export def GetDiagsByLine(bnr: number, lnum: number, lspserver: dict<any> = null_dict): list<dict<any>>
670 if !diagsMap->has_key(bnr)
674 var diags: list<dict<any>> = []
676 var serverDiagsByLnum = diagsMap[bnr].serverDiagnosticsByLnum
678 if lspserver == null_dict
679 for diagsByLnum in serverDiagsByLnum->values()
680 if diagsByLnum->has_key(lnum)
681 diags->extend(diagsByLnum[lnum])
685 if !serverDiagsByLnum->has_key(lspserver.id)
688 if serverDiagsByLnum[lspserver.id]->has_key(lnum)
689 diags = serverDiagsByLnum[lspserver.id][lnum]
693 return diags->sort((a, b) => {
694 return a.range.start.character - b.range.start.character
698 # Utility function to do the actual jump
699 def JumpDiag(diag: dict<any>)
700 var startPos: dict<number> = diag.range.start
701 setcursorcharpos(startPos.line + 1,
702 util.GetCharIdxWithoutCompChar(bufnr(), startPos) + 1)
704 if !opt.lspOptions.showDiagWithVirtualText
710 # jump to the next/previous/first diagnostic message in the current buffer
711 export def LspDiagsJump(which: string, a_count: number = 0): void
712 var fname: string = expand('%:p')
716 var bnr: number = bufnr()
718 if !diagsMap->has_key(bnr) ||
719 diagsMap[bnr].sortedDiagnostics->empty()
720 util.WarnMsg($'No diagnostic messages found for {fname}')
724 var diags = diagsMap[bnr].sortedDiagnostics
736 # Find the entry just before the current line (binary search)
737 var count = a_count > 1 ? a_count : 1
738 var curlnum: number = line('.')
739 var curcol: number = charcol('.')
740 for diag in (which == 'next' || which == 'here') ?
741 diags : diags->copy()->reverse()
742 var d_start = diag.range.start
743 var lnum = d_start.line + 1
744 var col = util.GetCharIdxWithoutCompChar(bnr, d_start) + 1
745 if (which == 'next' && (lnum > curlnum || lnum == curlnum && col > curcol))
746 || (which == 'prev' && (lnum < curlnum || lnum == curlnum
748 || (which == 'here' && (lnum == curlnum && col >= curcol))
750 # Skip over as many diags as "count" dictates
761 # If [count] exceeded the remaining diags
762 if which == 'next' && a_count > 1 && a_count != count
767 # If [count] exceeded the previous diags
768 if which == 'prev' && a_count > 1 && a_count != count
774 util.WarnMsg('No more diagnostics found on this line')
776 util.WarnMsg('No more diagnostics found')
780 # Disable the LSP diagnostics highlighting in all the buffers
781 export def DiagsHighlightDisable()
782 # turn off all diags highlight
783 opt.lspOptions.autoHighlightDiags = false
784 for binfo in getbufinfo({bufloaded: true})
785 RemoveDiagVisualsForBuffer(binfo.bufnr)
789 # Enable the LSP diagnostics highlighting
790 export def DiagsHighlightEnable()
791 opt.lspOptions.autoHighlightDiags = true
792 for binfo in getbufinfo({bufloaded: true})
793 DiagsRefresh(binfo.bufnr)
797 # Return the sorted diagnostics for buffer "bnr". Default is the current
798 # buffer. A copy of the diagnostics is returned so that the caller can modify
800 export def GetDiagsForBuf(bnr: number = bufnr()): list<dict<any>>
801 if !diagsMap->has_key(bnr) ||
802 diagsMap[bnr].sortedDiagnostics->empty()
806 return diagsMap[bnr].sortedDiagnostics->deepcopy()
809 # vim: tabstop=8 shiftwidth=2 softtabstop=2