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 used for LSP diagnostics
25 hlset([{name: 'LspDiagLine', default: true, linksto: 'DiffAdd'}])
26 hlset([{name: 'LspDiagSignErrorText', default: true, linksto: 'ErrorMsg'}])
27 hlset([{name: 'LspDiagSignWarningText', default: true, linksto: 'Search'}])
28 hlset([{name: 'LspDiagSignInfoText', default: true, linksto: 'Pmenu'}])
29 hlset([{name: 'LspDiagSignHintText', default: true, linksto: 'Question'}])
33 text: opt.lspOptions.diagSignErrorText,
34 texthl: 'LspDiagSignErrorText',
38 name: 'LspDiagWarning',
39 text: opt.lspOptions.diagSignWarningText,
40 texthl: 'LspDiagSignWarningText',
45 text: opt.lspOptions.diagSignInfoText,
46 texthl: 'LspDiagSignInfoText',
51 text: opt.lspOptions.diagSignHintText,
52 texthl: 'LspDiagSignHintText',
57 hlset([{name: 'LspDiagInlineError', default: true, linksto: 'SpellBad'}])
58 hlset([{name: 'LspDiagInlineWarning', default: true, linksto: 'SpellCap'}])
59 hlset([{name: 'LspDiagInlineInfo', default: true, linksto: 'SpellRare'}])
60 hlset([{name: 'LspDiagInlineHint', default: true, linksto: 'SpellLocal'}])
62 var override = &cursorline
63 && &cursorlineopt =~ '\<line\>\|\<screenline\>\|\<both\>'
65 prop_type_add('LspDiagInlineError',
66 { highlight: 'LspDiagInlineError',
69 prop_type_add('LspDiagInlineWarning',
70 { highlight: 'LspDiagInlineWarning',
73 prop_type_add('LspDiagInlineInfo',
74 { highlight: 'LspDiagInlineInfo',
77 prop_type_add('LspDiagInlineHint',
78 { highlight: 'LspDiagInlineHint',
82 hlset([{name: 'LspDiagVirtualText', default: true, linksto: 'LineNr'}])
83 prop_type_add('LspDiagVirtualText', {highlight: 'LspDiagVirtualText',
86 if opt.lspOptions.aleSupport
87 autocmd_add([{group: 'LspAleCmds', event: 'User', pattern: 'ALEWantResults', cmd: 'AleHook(g:ale_want_results_buffer)'}])
91 # Sort diagnostics ascending based on line and character offset
92 def SortDiags(diags: list<dict<any>>): list<dict<any>>
93 return diags->sort((a, b) => {
94 var linediff = a.range.start.line - b.range.start.line
96 return a.range.start.character - b.range.start.character
102 # Remove the diagnostics stored for buffer "bnr"
103 export def DiagRemoveFile(bnr: number)
104 if diagsMap->has_key(bnr)
105 diagsMap->remove(bnr)
109 def DiagSevToSignName(severity: number): string
110 var typeMap: list<string> = ['LspDiagError', 'LspDiagWarning',
111 'LspDiagInfo', 'LspDiagHint']
115 return typeMap[severity - 1]
118 def DiagSevToInlineHLName(severity: number): string
119 var typeMap: list<string> = [
120 'LspDiagInlineError',
121 'LspDiagInlineWarning',
126 return 'LspDiagInlineHint'
128 return typeMap[severity - 1]
131 def DiagSevToSymbolText(severity: number): string
132 var typeMap: list<string> = [
133 opt.lspOptions.diagSignErrorText,
134 opt.lspOptions.diagSignWarningText,
135 opt.lspOptions.diagSignInfoText,
136 opt.lspOptions.diagSignHintText
139 return opt.lspOptions.diagSignHintText
141 return typeMap[severity - 1]
144 # Remove signs and text properties for diagnostics in buffer
145 def RemoveDiagVisualsForBuffer(bnr: number)
146 # Remove all the existing diagnostic signs
147 sign_unplace('LSPDiag', {buffer: bnr})
149 if opt.lspOptions.showDiagWithVirtualText
150 # Remove all the existing virtual text
151 prop_remove({type: 'LspDiagVirtualText', bufnr: bnr, all: true})
154 if opt.lspOptions.highlightDiagInline
155 # Remove all the existing virtual text
156 prop_remove({type: 'LspDiagInlineError', bufnr: bnr, all: true})
157 prop_remove({type: 'LspDiagInlineWarning', bufnr: bnr, all: true})
158 prop_remove({type: 'LspDiagInlineInfo', bufnr: bnr, all: true})
159 prop_remove({type: 'LspDiagInlineHint', bufnr: bnr, all: true})
163 # Refresh the placed diagnostics in buffer "bnr"
164 # This inline signs, inline props, and virtual text diagnostics
165 def DiagsRefresh(bnr: number)
168 RemoveDiagVisualsForBuffer(bnr)
170 if !diagsMap->has_key(bnr) ||
171 diagsMap[bnr].sortedDiagnostics->empty()
175 # Initialize default/fallback properties for diagnostic virtual text:
176 var diag_align: string = 'above'
177 var diag_wrap: string = 'truncate'
178 var diag_symbol: string = '┌─'
180 if opt.lspOptions.diagVirtualTextAlign == 'below'
182 diag_wrap = 'truncate'
184 elseif opt.lspOptions.diagVirtualTextAlign == 'after'
190 var signs: list<dict<any>> = []
191 var diags: list<dict<any>> = diagsMap[bnr].sortedDiagnostics
193 # TODO: prioritize most important severity if there are multiple diagnostics
195 var lnum = diag.range.start.line + 1
196 signs->add({id: 0, buffer: bnr, group: 'LSPDiag',
198 name: DiagSevToSignName(diag.severity),
199 priority: 10 - diag.severity})
202 if opt.lspOptions.highlightDiagInline
203 prop_add(diag.range.start.line + 1,
204 util.GetLineByteFromPos(bnr, diag.range.start) + 1,
205 {end_lnum: diag.range.end.line + 1,
206 end_col: util.GetLineByteFromPos(bnr, diag.range.end) + 1,
208 type: DiagSevToInlineHLName(diag.severity)})
211 if opt.lspOptions.showDiagWithVirtualText
214 var symbol: string = diag_symbol
216 if diag_align == 'after'
218 symbol = DiagSevToSymbolText(diag.severity)
220 var charIdx = util.GetCharIdxWithoutCompChar(bnr, diag.range.start)
223 padding = strdisplaywidth(getline(diag.range.start.line + 1)[ : charIdx - 1])
227 prop_add(lnum, 0, {bufnr: bnr,
228 type: 'LspDiagVirtualText',
229 text: $'{symbol} {diag.message}',
230 text_align: diag_align,
231 text_wrap: diag_wrap,
232 text_padding_left: padding})
234 catch /E966\|E964/ # Invalid lnum | Invalid col
235 # Diagnostics arrive asynchronous and the document changed while they wore
236 # send. Ignore this as new once will arrive shortly.
240 signs->sign_placelist()
243 # Sends diagnostics to Ale
244 def SendAleDiags(bnr: number, timerid: number)
245 if !diagsMap->has_key(bnr)
249 # Conver to Ale's diagnostics format (:h ale-loclist-format)
250 ale#other_source#ShowResults(bnr, 'lsp', diagsMap[bnr].sortedDiagnostics->mapnew((_, v) => {
251 return {text: v.message,
252 lnum: v.range.start.line + 1,
253 col: util.GetLineByteFromPos(bnr, v.range.start) + 1,
254 end_lnum: v.range.end.line + 1,
255 end_col: util.GetLineByteFromPos(bnr, v.range.end) + 1,
256 type: "EWIH"[v.severity - 1]}
260 # Hook called when Ale wants to retrieve new diagnostics
261 def AleHook(bnr: number)
262 ale#other_source#StartChecking(bnr, 'lsp')
263 timer_start(0, function('SendAleDiags', [bnr]))
266 # New LSP diagnostic messages received from the server for a file.
267 # Update the signs placed in the buffer for this file
268 export def ProcessNewDiags(bnr: number)
269 DiagsUpdateLocList(bnr)
271 if opt.lspOptions.aleSupport
272 SendAleDiags(bnr, -1)
274 elseif !opt.lspOptions.autoHighlightDiags
278 if bnr == -1 || !diagsMap->has_key(bnr)
282 var curmode: string = mode()
283 if curmode == 'i' || curmode == 'R' || curmode == 'Rv'
284 # postpone placing signs in insert mode and replace mode. These will be
285 # placed after the user returns to Normal mode.
286 b:LspDiagsUpdatePending = true
293 # process a diagnostic notification message from the LSP server
294 # Notification: textDocument/publishDiagnostics
295 # Param: PublishDiagnosticsParams
296 export def DiagNotification(lspserver: dict<any>, uri: string, diags_arg: list<dict<any>>): void
297 # Diagnostics are disabled for this server
298 if lspserver.features->has_key('diagnostics') && !lspserver.features.diagnostics
302 var fname: string = util.LspUriToFile(uri)
303 var bnr: number = fname->bufnr()
305 # Is this condition possible?
309 var newDiags: list<dict<any>> = diags_arg
311 if lspserver.needOffsetEncoding
312 # Decode the position encoding in all the diags
313 newDiags->map((_, dval) => {
314 lspserver.decodeRange(bnr, dval.range)
319 if lspserver.processDiagHandler != null_function
320 newDiags = lspserver.processDiagHandler(diags_arg)
323 # TODO: Is the buffer (bnr) always a loaded buffer? Should we load it here?
324 var lastlnum: number = bnr->getbufinfo()[0].linecount
326 # store the diagnostic for each line separately
327 var diagsByLnum: dict<list<dict<any>>> = {}
329 var diagWithinRange: list<dict<any>> = []
331 if diag.range.start.line + 1 > lastlnum
332 # Make sure the line number is a valid buffer line number
333 diag.range.start.line = lastlnum - 1
336 var lnum = diag.range.start.line + 1
337 if !diagsByLnum->has_key(lnum)
338 diagsByLnum[lnum] = []
340 diagsByLnum[lnum]->add(diag)
342 diagWithinRange->add(diag)
345 var serverDiags: dict<list<any>> = diagsMap->has_key(bnr) ?
346 diagsMap[bnr].serverDiagnostics : {}
347 serverDiags[lspserver.id] = diagWithinRange
349 var serverDiagsByLnum: dict<dict<list<any>>> = diagsMap->has_key(bnr) ?
350 diagsMap[bnr].serverDiagnosticsByLnum : {}
351 serverDiagsByLnum[lspserver.id] = diagsByLnum
353 # store the diagnostic for each line separately
354 var joinedServerDiags: list<dict<any>> = []
355 for diags in serverDiags->values()
357 joinedServerDiags->add(diag)
361 var sortedDiags = SortDiags(joinedServerDiags)
364 sortedDiagnostics: sortedDiags,
365 serverDiagnosticsByLnum: serverDiagsByLnum,
366 serverDiagnostics: serverDiags
371 # Notify user scripts that diags has been updated
372 if exists('#User#LspDiagsUpdated')
373 :doautocmd <nomodeline> User LspDiagsUpdated
377 # get the count of error in the current buffer
378 export def DiagsGetErrorCount(): dict<number>
384 var bnr: number = bufnr()
385 if diagsMap->has_key(bnr)
386 var diags = diagsMap[bnr].sortedDiagnostics
388 var severity = diag->get('severity', -1)
401 return {Error: errCount, Warn: warnCount, Info: infoCount, Hint: hintCount}
404 # Map the LSP DiagnosticSeverity to a quickfix type character
405 def DiagSevToQfType(severity: number): string
406 var typeMap: list<string> = ['E', 'W', 'I', 'N']
412 return typeMap[severity - 1]
415 # Update the location list window for the current window with the diagnostic
417 # Returns true if diagnostics is not empty and false if it is empty.
418 def DiagsUpdateLocList(bnr: number, calledByCmd: bool = false): bool
419 var fname: string = bnr->bufname()->fnamemodify(':p')
424 var LspQfId: number = bnr->getbufvar('LspQfId', 0)
425 if LspQfId->empty() && !opt.lspOptions.autoPopulateDiags && !calledByCmd
426 # If a location list for the diagnostics was not opened previously,
427 # and 'autoPopulateDiags' is set to false, then do nothing.
431 if !LspQfId->empty() && getloclist(0, {id: LspQfId}).id != LspQfId
432 # Previously used location list for the diagnostics is gone
436 if !diagsMap->has_key(bnr) ||
437 diagsMap[bnr].sortedDiagnostics->empty()
439 setloclist(0, [], 'r', {id: LspQfId, items: []})
444 var qflist: list<dict<any>> = []
447 var diags = diagsMap[bnr].sortedDiagnostics
449 text = diag.message->substitute("\n\\+", "\n", 'g')
450 qflist->add({filename: fname,
451 lnum: diag.range.start.line + 1,
452 col: util.GetLineByteFromPos(bnr, diag.range.start) + 1,
453 end_lnum: diag.range.end.line + 1,
454 end_col: util.GetLineByteFromPos(bnr, diag.range.end) + 1,
456 type: DiagSevToQfType(diag.severity)})
460 var props = {title: 'Language Server Diagnostics', items: qflist}
465 setloclist(0, [], op, props)
467 setbufvar(bnr, 'LspQfId', getloclist(0, {id: 0}).id)
473 # Display the diagnostic messages from the LSP server for the current buffer
475 export def ShowAllDiags(): void
476 if !DiagsUpdateLocList(bufnr(), true)
477 util.WarnMsg($'No diagnostic messages found for {@%}')
481 var save_winid = win_getid()
482 # make the diagnostics error list the active one and open it
483 var LspQfId: number = getbufvar(bufnr(), 'LspQfId', 0)
484 var LspQfNr: number = getloclist(0, {id: LspQfId, nr: 0}).nr
485 exe $':{LspQfNr} lhistory'
487 if !opt.lspOptions.keepFocusInDiags
488 save_winid->win_gotoid()
492 # Display the message of "diag" in a popup window right below the position in
493 # the diagnostic message.
494 def ShowDiagInPopup(diag: dict<any>)
495 var dlnum = diag.range.start.line + 1
496 var ltext = dlnum->getline()
497 var dlcol = ltext->byteidxcomp(diag.range.start.character) + 1
499 var lastline = line('$')
501 # The line number is outside the last line in the file.
505 # The column is outside the last character in line.
506 dlcol = ltext->len() + 1
508 var d = screenpos(0, dlnum, dlcol)
510 # If the diag position cannot be converted to Vim lnum/col, then use
511 # the current cursor position
512 d = {row: line('.'), col: col('.')}
515 # Display a popup right below the diagnostics position
516 var msg = diag.message->split("\n")
517 var msglen = msg->reduce((acc, val) => max([acc, val->strcharlen()]), 0)
520 ppopts.pos = 'topleft'
521 ppopts.line = d.row + 1
532 popup_create(msg, ppopts)
535 # Display the "diag" message in a popup or in the status message area
536 def DisplayDiag(diag: dict<any>)
537 if opt.lspOptions.showDiagInPopup
538 # Display the diagnostic message in a popup window.
539 ShowDiagInPopup(diag)
541 # Display the diagnostic message in the status message area
546 # Show the diagnostic message for the current line
547 export def ShowCurrentDiag(atPos: bool)
548 var bnr: number = bufnr()
549 var lnum: number = line('.')
550 var col: number = charcol('.')
551 var diag: dict<any> = GetDiagByPos(bnr, lnum, col, atPos)
553 util.WarnMsg($'No diagnostic messages found for current {atPos ? "position" : "line"}')
559 # Show the diagnostic message for the current line without linebreak
560 export def ShowCurrentDiagInStatusLine()
561 var bnr: number = bufnr()
562 var lnum: number = line('.')
563 var col: number = charcol('.')
564 var diag: dict<any> = GetDiagByPos(bnr, lnum, col)
566 # 15 is a enough length not to cause line break
567 var max_width = &columns - 15
569 if diag->has_key('code')
570 code = $'[{diag.code}] '
572 var msgNoLineBreak = code .. substitute(substitute(diag.message, "\n", ' ', ''), "\\n", ' ', '')
573 :echo msgNoLineBreak[ : max_width]
579 # Get the diagnostic from the LSP server for a particular line and character
581 export def GetDiagByPos(bnr: number, lnum: number, col: number,
582 atPos: bool = false): dict<any>
583 var diags_in_line = GetDiagsByLine(bnr, lnum)
585 for diag in diags_in_line
586 var startCharIdx = util.GetCharIdxWithoutCompChar(bnr, diag.range.start)
587 var endCharIdx = util.GetCharIdxWithoutCompChar(bnr, diag.range.end)
589 if col >= startCharIdx + 1 && col < endCharIdx + 1
592 elseif col <= startCharIdx + 1
597 # No diagnostic to the right of the position, return the last one instead
598 if !atPos && diags_in_line->len() > 0
599 return diags_in_line[-1]
605 # Get all diagnostics from the LSP server for a particular line in a file
606 export def GetDiagsByLine(bnr: number, lnum: number, lspserver: dict<any> = null_dict): list<dict<any>>
607 if !diagsMap->has_key(bnr)
611 var diags: list<dict<any>> = []
613 var serverDiagsByLnum = diagsMap[bnr].serverDiagnosticsByLnum
615 if lspserver == null_dict
616 for diagsByLnum in serverDiagsByLnum->values()
617 if diagsByLnum->has_key(lnum)
618 diags->extend(diagsByLnum[lnum])
622 if !serverDiagsByLnum->has_key(lspserver.id)
625 if serverDiagsByLnum[lspserver.id]->has_key(lnum)
626 diags = serverDiagsByLnum[lspserver.id][lnum]
630 return diags->sort((a, b) => {
631 return a.range.start.character - b.range.start.character
635 # Utility function to do the actual jump
636 def JumpDiag(diag: dict<any>)
637 var startPos: dict<number> = diag.range.start
638 setcursorcharpos(startPos.line + 1,
639 util.GetCharIdxWithoutCompChar(bufnr(), startPos) + 1)
640 if !opt.lspOptions.showDiagWithVirtualText
646 # jump to the next/previous/first diagnostic message in the current buffer
647 export def LspDiagsJump(which: string, a_count: number = 0): void
648 var fname: string = expand('%:p')
652 var bnr: number = bufnr()
654 if !diagsMap->has_key(bnr) ||
655 diagsMap[bnr].sortedDiagnostics->empty()
656 util.WarnMsg($'No diagnostic messages found for {fname}')
660 var diags = diagsMap[bnr].sortedDiagnostics
672 # Find the entry just before the current line (binary search)
673 var count = a_count > 1 ? a_count : 1
674 var curlnum: number = line('.')
675 var curcol: number = charcol('.')
676 for diag in (which == 'next' || which == 'here') ?
677 diags : diags->copy()->reverse()
678 var lnum = diag.range.start.line + 1
679 var col = util.GetCharIdxWithoutCompChar(bnr, diag.range.start) + 1
680 if (which == 'next' && (lnum > curlnum || lnum == curlnum && col > curcol))
681 || (which == 'prev' && (lnum < curlnum || lnum == curlnum
683 || (which == 'here' && (lnum == curlnum && col >= curcol))
685 # Skip over as many diags as "count" dictates
696 # If [count] exceeded the remaining diags
697 if which == 'next' && a_count > 1 && a_count != count
702 # If [count] exceeded the previous diags
703 if which == 'prev' && a_count > 1 && a_count != count
709 util.WarnMsg('No more diagnostics found on this line')
711 util.WarnMsg('No more diagnostics found')
715 # Disable the LSP diagnostics highlighting in all the buffers
716 export def DiagsHighlightDisable()
717 # turn off all diags highlight
718 opt.lspOptions.autoHighlightDiags = false
719 for binfo in getbufinfo({bufloaded: true})
720 RemoveDiagVisualsForBuffer(binfo.bufnr)
724 # Enable the LSP diagnostics highlighting
725 export def DiagsHighlightEnable()
726 opt.lspOptions.autoHighlightDiags = true
727 for binfo in getbufinfo({bufloaded: true})
728 DiagsRefresh(binfo.bufnr)
732 # vim: tabstop=8 shiftwidth=2 softtabstop=2