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
104 autocmd_add([{group: 'LspAleCmds', event: 'User', pattern: 'ALEWantResults', cmd: 'AleHook(g:ale_want_results_buffer)'}])
108 # Sort diagnostics ascending based on line and character offset
109 def SortDiags(diags: list<dict<any>>): list<dict<any>>
110 return diags->sort((a, b) => {
111 var linediff = a.range.start.line - b.range.start.line
113 return a.range.start.character - b.range.start.character
119 # Remove the diagnostics stored for buffer "bnr"
120 export def DiagRemoveFile(bnr: number)
121 if diagsMap->has_key(bnr)
122 diagsMap->remove(bnr)
126 def DiagSevToSignName(severity: number): string
127 var typeMap: list<string> = ['LspDiagError', 'LspDiagWarning',
128 'LspDiagInfo', 'LspDiagHint']
132 return typeMap[severity - 1]
135 def DiagSevToInlineHLName(severity: number): string
136 var typeMap: list<string> = [
137 'LspDiagInlineError',
138 'LspDiagInlineWarning',
143 return 'LspDiagInlineHint'
145 return typeMap[severity - 1]
148 def DiagSevToVirtualTextHLName(severity: number): string
149 var typeMap: list<string> = [
150 'LspDiagVirtualTextError',
151 'LspDiagVirtualTextWarning',
152 'LspDiagVirtualTextInfo',
153 'LspDiagVirtualTextHint'
156 return 'LspDiagVirtualTextHint'
158 return typeMap[severity - 1]
161 def DiagSevToSymbolText(severity: number): string
162 var typeMap: list<string> = [
163 opt.lspOptions.diagSignErrorText,
164 opt.lspOptions.diagSignWarningText,
165 opt.lspOptions.diagSignInfoText,
166 opt.lspOptions.diagSignHintText
169 return opt.lspOptions.diagSignHintText
171 return typeMap[severity - 1]
174 # Remove signs and text properties for diagnostics in buffer
175 def RemoveDiagVisualsForBuffer(bnr: number)
176 if opt.lspOptions.showDiagWithSign
177 # Remove all the existing diagnostic signs
178 sign_unplace('LSPDiag', {buffer: bnr})
181 if opt.lspOptions.showDiagWithVirtualText
182 # Remove all the existing virtual text
183 prop_remove({type: 'LspDiagVirtualTextError', bufnr: bnr, all: true})
184 prop_remove({type: 'LspDiagVirtualTextWarning', bufnr: bnr, all: true})
185 prop_remove({type: 'LspDiagVirtualTextInfo', bufnr: bnr, all: true})
186 prop_remove({type: 'LspDiagVirtualTextHint', bufnr: bnr, all: true})
189 if opt.lspOptions.highlightDiagInline
190 # Remove all the existing virtual text
191 prop_remove({type: 'LspDiagInlineError', bufnr: bnr, all: true})
192 prop_remove({type: 'LspDiagInlineWarning', bufnr: bnr, all: true})
193 prop_remove({type: 'LspDiagInlineInfo', bufnr: bnr, all: true})
194 prop_remove({type: 'LspDiagInlineHint', bufnr: bnr, all: true})
198 # Refresh the placed diagnostics in buffer "bnr"
199 # This inline signs, inline props, and virtual text diagnostics
200 def DiagsRefresh(bnr: number)
201 :silent! bnr->bufload()
203 RemoveDiagVisualsForBuffer(bnr)
205 if !diagsMap->has_key(bnr) ||
206 diagsMap[bnr].sortedDiagnostics->empty()
210 # Initialize default/fallback properties for diagnostic virtual text:
211 var diag_align: string = 'above'
212 var diag_wrap: string = 'truncate'
213 var diag_symbol: string = '┌─'
215 if opt.lspOptions.diagVirtualTextAlign == 'below'
217 diag_wrap = 'truncate'
219 elseif opt.lspOptions.diagVirtualTextAlign == 'after'
225 var signs: list<dict<any>> = []
226 var diags: list<dict<any>> = diagsMap[bnr].sortedDiagnostics
228 # TODO: prioritize most important severity if there are multiple diagnostics
230 var lnum = diag.range.start.line + 1
231 if opt.lspOptions.showDiagWithSign
232 signs->add({id: 0, buffer: bnr, group: 'LSPDiag',
233 lnum: lnum, name: DiagSevToSignName(diag.severity),
234 priority: 10 - diag.severity})
238 if opt.lspOptions.highlightDiagInline
239 prop_add(diag.range.start.line + 1,
240 util.GetLineByteFromPos(bnr, diag.range.start) + 1,
241 {end_lnum: diag.range.end.line + 1,
242 end_col: util.GetLineByteFromPos(bnr, diag.range.end) + 1,
244 type: DiagSevToInlineHLName(diag.severity)})
247 if opt.lspOptions.showDiagWithVirtualText
250 var symbol: string = diag_symbol
252 if diag_align == 'after'
254 symbol = DiagSevToSymbolText(diag.severity)
256 var charIdx = util.GetCharIdxWithoutCompChar(bnr, diag.range.start)
259 padding = strdisplaywidth(getline(diag.range.start.line + 1)[ : charIdx - 1])
263 prop_add(lnum, 0, {bufnr: bnr,
264 type: DiagSevToVirtualTextHLName(diag.severity),
265 text: $'{symbol} {diag.message}',
266 text_align: diag_align,
267 text_wrap: diag_wrap,
268 text_padding_left: padding})
270 catch /E966\|E964/ # Invalid lnum | Invalid col
271 # Diagnostics arrive asynchronous and the document changed while they wore
272 # send. Ignore this as new once will arrive shortly.
276 if opt.lspOptions.showDiagWithSign
277 signs->sign_placelist()
281 # Sends diagnostics to Ale
282 def SendAleDiags(bnr: number, timerid: number)
283 if !diagsMap->has_key(bnr)
287 # Convert to Ale's diagnostics format (:h ale-loclist-format)
288 ale#other_source#ShowResults(bnr, 'lsp', diagsMap[bnr].sortedDiagnostics->mapnew((_, v) => {
289 return {text: v.message,
290 lnum: v.range.start.line + 1,
291 col: util.GetLineByteFromPos(bnr, v.range.start) + 1,
292 end_lnum: v.range.end.line + 1,
293 end_col: util.GetLineByteFromPos(bnr, v.range.end) + 1,
294 type: "EWIH"[v.severity - 1]}
298 # Hook called when Ale wants to retrieve new diagnostics
299 def AleHook(bnr: number)
300 ale#other_source#StartChecking(bnr, 'lsp')
301 timer_start(0, function('SendAleDiags', [bnr]))
304 # New LSP diagnostic messages received from the server for a file.
305 # Update the signs placed in the buffer for this file
306 export def ProcessNewDiags(bnr: number)
307 DiagsUpdateLocList(bnr)
309 if opt.lspOptions.aleSupport
310 SendAleDiags(bnr, -1)
312 elseif !opt.lspOptions.autoHighlightDiags
316 if bnr == -1 || !diagsMap->has_key(bnr)
320 var curmode: string = mode()
321 if curmode == 'i' || curmode == 'R' || curmode == 'Rv'
322 # postpone placing signs in insert mode and replace mode. These will be
323 # placed after the user returns to Normal mode.
324 b:LspDiagsUpdatePending = true
331 # process a diagnostic notification message from the LSP server
332 # Notification: textDocument/publishDiagnostics
333 # Param: PublishDiagnosticsParams
334 export def DiagNotification(lspserver: dict<any>, uri: string, diags_arg: list<dict<any>>): void
335 # Diagnostics are disabled for this server
336 if lspserver.features->has_key('diagnostics') && !lspserver.features.diagnostics
340 var fname: string = util.LspUriToFile(uri)
341 var bnr: number = fname->bufnr()
343 # Is this condition possible?
347 var newDiags: list<dict<any>> = diags_arg
349 if lspserver.needOffsetEncoding
350 # Decode the position encoding in all the diags
351 newDiags->map((_, dval) => {
352 lspserver.decodeRange(bnr, dval.range)
357 if lspserver.processDiagHandler != null_function
358 newDiags = lspserver.processDiagHandler(diags_arg)
361 # TODO: Is the buffer (bnr) always a loaded buffer? Should we load it here?
362 var lastlnum: number = bnr->getbufinfo()[0].linecount
364 # store the diagnostic for each line separately
365 var diagsByLnum: dict<list<dict<any>>> = {}
367 var diagWithinRange: list<dict<any>> = []
369 if diag.range.start.line + 1 > lastlnum
370 # Make sure the line number is a valid buffer line number
371 diag.range.start.line = lastlnum - 1
374 var lnum = diag.range.start.line + 1
375 if !diagsByLnum->has_key(lnum)
376 diagsByLnum[lnum] = []
378 diagsByLnum[lnum]->add(diag)
380 diagWithinRange->add(diag)
383 var serverDiags: dict<list<any>> = diagsMap->has_key(bnr) ?
384 diagsMap[bnr].serverDiagnostics : {}
385 serverDiags[lspserver.id] = diagWithinRange
387 var serverDiagsByLnum: dict<dict<list<any>>> = diagsMap->has_key(bnr) ?
388 diagsMap[bnr].serverDiagnosticsByLnum : {}
389 serverDiagsByLnum[lspserver.id] = diagsByLnum
391 # store the diagnostic for each line separately
392 var joinedServerDiags: list<dict<any>> = []
393 for diags in serverDiags->values()
395 joinedServerDiags->add(diag)
399 var sortedDiags = SortDiags(joinedServerDiags)
402 sortedDiagnostics: sortedDiags,
403 serverDiagnosticsByLnum: serverDiagsByLnum,
404 serverDiagnostics: serverDiags
409 # Notify user scripts that diags has been updated
410 if exists('#User#LspDiagsUpdated')
411 :doautocmd <nomodeline> User LspDiagsUpdated
415 # get the count of error in the current buffer
416 export def DiagsGetErrorCount(): dict<number>
422 var bnr: number = bufnr()
423 if diagsMap->has_key(bnr)
424 var diags = diagsMap[bnr].sortedDiagnostics
426 var severity = diag->get('severity', -1)
439 return {Error: errCount, Warn: warnCount, Info: infoCount, Hint: hintCount}
442 # Map the LSP DiagnosticSeverity to a quickfix type character
443 def DiagSevToQfType(severity: number): string
444 var typeMap: list<string> = ['E', 'W', 'I', 'N']
450 return typeMap[severity - 1]
453 # Update the location list window for the current window with the diagnostic
455 # Returns true if diagnostics is not empty and false if it is empty.
456 def DiagsUpdateLocList(bnr: number, calledByCmd: bool = false): bool
457 var fname: string = bnr->bufname()->fnamemodify(':p')
462 var LspQfId: number = bnr->getbufvar('LspQfId', 0)
463 if LspQfId == 0 && !opt.lspOptions.autoPopulateDiags && !calledByCmd
464 # Diags location list is not present. Create the location list only if
465 # the 'autoPopulateDiags' option is set or the :LspDiagShow command is
470 if LspQfId != 0 && getloclist(0, {id: LspQfId}).id != LspQfId
471 # Previously used location list for the diagnostics is gone
475 if !diagsMap->has_key(bnr) ||
476 diagsMap[bnr].sortedDiagnostics->empty()
478 setloclist(0, [], 'r', {id: LspQfId, items: []})
483 var qflist: list<dict<any>> = []
486 var diags = diagsMap[bnr].sortedDiagnostics
488 text = diag.message->substitute("\n\\+", "\n", 'g')
489 qflist->add({filename: fname,
490 lnum: diag.range.start.line + 1,
491 col: util.GetLineByteFromPos(bnr, diag.range.start) + 1,
492 end_lnum: diag.range.end.line + 1,
493 end_col: util.GetLineByteFromPos(bnr, diag.range.end) + 1,
495 type: DiagSevToQfType(diag.severity)})
499 var props = {title: 'Language Server Diagnostics', items: qflist}
504 setloclist(0, [], op, props)
506 setbufvar(bnr, 'LspQfId', getloclist(0, {id: 0}).id)
512 # Display the diagnostic messages from the LSP server for the current buffer
514 export def ShowAllDiags(): void
515 if !DiagsUpdateLocList(bufnr(), true)
516 util.WarnMsg($'No diagnostic messages found for {@%}')
520 var save_winid = win_getid()
521 # make the diagnostics error list the active one and open it
522 var LspQfId: number = getbufvar(bufnr(), 'LspQfId', 0)
523 var LspQfNr: number = getloclist(0, {id: LspQfId, nr: 0}).nr
524 exe $':{LspQfNr} lhistory'
526 if !opt.lspOptions.keepFocusInDiags
527 save_winid->win_gotoid()
531 # Display the message of "diag" in a popup window right below the position in
532 # the diagnostic message.
533 def ShowDiagInPopup(diag: dict<any>)
534 var dlnum = diag.range.start.line + 1
535 var ltext = dlnum->getline()
536 var dlcol = ltext->byteidxcomp(diag.range.start.character) + 1
538 var lastline = line('$')
540 # The line number is outside the last line in the file.
544 # The column is outside the last character in line.
545 dlcol = ltext->len() + 1
547 var d = screenpos(0, dlnum, dlcol)
549 # If the diag position cannot be converted to Vim lnum/col, then use
550 # the current cursor position
551 d = {row: line('.'), col: col('.')}
554 # Display a popup right below the diagnostics position
555 var msg = diag.message->split("\n")
556 var msglen = msg->reduce((acc, val) => max([acc, val->strcharlen()]), 0)
559 ppopts.pos = 'topleft'
560 ppopts.line = d.row + 1
571 popup_create(msg, ppopts)
574 # Display the "diag" message in a popup or in the status message area
575 def DisplayDiag(diag: dict<any>)
576 if opt.lspOptions.showDiagInPopup
577 # Display the diagnostic message in a popup window.
578 ShowDiagInPopup(diag)
580 # Display the diagnostic message in the status message area
585 # Show the diagnostic message for the current line
586 export def ShowCurrentDiag(atPos: bool)
587 var bnr: number = bufnr()
588 var lnum: number = line('.')
589 var col: number = charcol('.')
590 var diag: dict<any> = GetDiagByPos(bnr, lnum, col, atPos)
592 util.WarnMsg($'No diagnostic messages found for current {atPos ? "position" : "line"}')
598 # Show the diagnostic message for the current line without linebreak
599 export def ShowCurrentDiagInStatusLine()
600 var bnr: number = bufnr()
601 var lnum: number = line('.')
602 var col: number = charcol('.')
603 var diag: dict<any> = GetDiagByPos(bnr, lnum, col)
605 # 15 is a enough length not to cause line break
606 var max_width = &columns - 15
608 if diag->has_key('code')
609 code = $'[{diag.code}] '
611 var msgNoLineBreak = code .. substitute(substitute(diag.message, "\n", ' ', ''), "\\n", ' ', '')
612 :echo msgNoLineBreak[ : max_width]
618 # Get the diagnostic from the LSP server for a particular line and character
620 export def GetDiagByPos(bnr: number, lnum: number, col: number,
621 atPos: bool = false): dict<any>
622 var diags_in_line = GetDiagsByLine(bnr, lnum)
624 for diag in diags_in_line
625 var startCharIdx = util.GetCharIdxWithoutCompChar(bnr, diag.range.start)
626 var endCharIdx = util.GetCharIdxWithoutCompChar(bnr, diag.range.end)
628 if col >= startCharIdx + 1 && col < endCharIdx + 1
631 elseif col <= startCharIdx + 1
636 # No diagnostic to the right of the position, return the last one instead
637 if !atPos && diags_in_line->len() > 0
638 return diags_in_line[-1]
644 # Get all diagnostics from the LSP server for a particular line in a file
645 export def GetDiagsByLine(bnr: number, lnum: number, lspserver: dict<any> = null_dict): list<dict<any>>
646 if !diagsMap->has_key(bnr)
650 var diags: list<dict<any>> = []
652 var serverDiagsByLnum = diagsMap[bnr].serverDiagnosticsByLnum
654 if lspserver == null_dict
655 for diagsByLnum in serverDiagsByLnum->values()
656 if diagsByLnum->has_key(lnum)
657 diags->extend(diagsByLnum[lnum])
661 if !serverDiagsByLnum->has_key(lspserver.id)
664 if serverDiagsByLnum[lspserver.id]->has_key(lnum)
665 diags = serverDiagsByLnum[lspserver.id][lnum]
669 return diags->sort((a, b) => {
670 return a.range.start.character - b.range.start.character
674 # Utility function to do the actual jump
675 def JumpDiag(diag: dict<any>)
676 var startPos: dict<number> = diag.range.start
677 setcursorcharpos(startPos.line + 1,
678 util.GetCharIdxWithoutCompChar(bufnr(), startPos) + 1)
680 if !opt.lspOptions.showDiagWithVirtualText
686 # jump to the next/previous/first diagnostic message in the current buffer
687 export def LspDiagsJump(which: string, a_count: number = 0): void
688 var fname: string = expand('%:p')
692 var bnr: number = bufnr()
694 if !diagsMap->has_key(bnr) ||
695 diagsMap[bnr].sortedDiagnostics->empty()
696 util.WarnMsg($'No diagnostic messages found for {fname}')
700 var diags = diagsMap[bnr].sortedDiagnostics
712 # Find the entry just before the current line (binary search)
713 var count = a_count > 1 ? a_count : 1
714 var curlnum: number = line('.')
715 var curcol: number = charcol('.')
716 for diag in (which == 'next' || which == 'here') ?
717 diags : diags->copy()->reverse()
718 var lnum = diag.range.start.line + 1
719 var col = util.GetCharIdxWithoutCompChar(bnr, diag.range.start) + 1
720 if (which == 'next' && (lnum > curlnum || lnum == curlnum && col > curcol))
721 || (which == 'prev' && (lnum < curlnum || lnum == curlnum
723 || (which == 'here' && (lnum == curlnum && col >= curcol))
725 # Skip over as many diags as "count" dictates
736 # If [count] exceeded the remaining diags
737 if which == 'next' && a_count > 1 && a_count != count
742 # If [count] exceeded the previous diags
743 if which == 'prev' && a_count > 1 && a_count != count
749 util.WarnMsg('No more diagnostics found on this line')
751 util.WarnMsg('No more diagnostics found')
755 # Disable the LSP diagnostics highlighting in all the buffers
756 export def DiagsHighlightDisable()
757 # turn off all diags highlight
758 opt.lspOptions.autoHighlightDiags = false
759 for binfo in getbufinfo({bufloaded: true})
760 RemoveDiagVisualsForBuffer(binfo.bufnr)
764 # Enable the LSP diagnostics highlighting
765 export def DiagsHighlightEnable()
766 opt.lspOptions.autoHighlightDiags = true
767 for binfo in getbufinfo({bufloaded: true})
768 DiagsRefresh(binfo.bufnr)
772 # Return the sorted diagnostics for buffer "bnr". Default is the current
773 # buffer. A copy of the diagnostics is returned so that the caller can modify
775 export def GetDiagsForBuf(bnr: number = bufnr()): list<dict<any>>
776 if !diagsMap->has_key(bnr) ||
777 diagsMap[bnr].sortedDiagnostics->empty()
781 return diagsMap[bnr].sortedDiagnostics->deepcopy()
784 # vim: tabstop=8 shiftwidth=2 softtabstop=2