summaryrefslogtreecommitdiffstatshomepage
path: root/runtime/lua/vim/diagnostic/_float.lua
blob: 3cd1452cd158200c570b828ff3fcd7fc32847b77 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
local api, if_nil = vim.api, vim.F.if_nil
local shared = require('vim.diagnostic._shared')
local store = require('vim.diagnostic._store')

--- @class (private) vim.diagnostic._float
local M = {}

local severity = vim.diagnostic.severity

--- @type table<vim.diagnostic.Severity, string>
local floating_highlight_map = {
  [severity.ERROR] = 'DiagnosticFloatingError',
  [severity.WARN] = 'DiagnosticFloatingWarn',
  [severity.INFO] = 'DiagnosticFloatingInfo',
  [severity.HINT] = 'DiagnosticFloatingHint',
}

--- @param opts vim.diagnostic.Opts.Float
--- @param bufnr integer
--- @return vim.diagnostic.Opts.Float, vim.diagnostic.Opts
local function resolve_float_opts(opts, bufnr)
  -- Resolve options with user settings from vim.diagnostic.config
  -- Unlike the other decoration functions (e.g. set_virtual_text, set_signs, etc.) `open_float`
  -- does not have a dedicated table for configuration options; instead, the options are mixed in
  -- with its `opts` table. We create a dedicated options table (`float_opts`) that inherits
  -- missing keys from the global configuration (`global_diagnostic_options.float`), which can
  -- be a table or a function.
  local global_opts = assert(vim.diagnostic.config())
  local float_opts = global_opts.float
  local resolved_float_opts = type(float_opts) == 'table' and float_opts
    or (type(float_opts) == 'function' and float_opts(opts.namespace, bufnr) or {})

  return vim.tbl_extend('keep', opts, resolved_float_opts), global_opts
end

--- @param opts vim.diagnostic.Opts.Float?
--- @return integer? float_bufnr
--- @return integer? winid
function M.open(opts, ...)
  -- Support old (bufnr, opts) signature
  local bufnr --- @type integer?
  if opts == nil or type(opts) == 'number' then
    bufnr = opts
    opts = ... --- @type vim.diagnostic.Opts.Float
  else
    vim.validate('opts', opts, 'table', true)
  end

  opts = opts or {}
  bufnr = vim._resolve_bufnr(bufnr or opts.bufnr)
  local global_opts --- @type vim.diagnostic.Opts
  opts, global_opts = resolve_float_opts(opts, bufnr)

  local scope = ({ l = 'line', c = 'cursor', b = 'buffer' })[opts.scope] or opts.scope or 'line'
  local lnum, col --- @type integer, integer
  local opts_pos = opts.pos
  if scope == 'line' or scope == 'cursor' then
    if not opts_pos then
      local pos = api.nvim_win_get_cursor(0)
      lnum = pos[1] - 1
      col = pos[2]
    elseif type(opts_pos) == 'number' then
      lnum = opts_pos
    elseif type(opts_pos) == 'table' then
      lnum, col = opts_pos[1], opts_pos[2]
    else
      error("Invalid value for option 'pos'")
    end
  elseif scope ~= 'buffer' then
    error("Invalid value for option 'scope'")
  end

  local diagnostics = store.get_diagnostics(bufnr, opts --[[@as vim.diagnostic.GetOpts]], true)

  if scope == 'line' then
    --- @param diagnostic vim.Diagnostic
    local function line_filter(diagnostic)
      local d_lnum, _, d_end_lnum, d_end_col = shared.get_logical_pos(diagnostic)
      return lnum >= d_lnum
        and lnum <= d_end_lnum
        and (d_lnum == d_end_lnum or lnum ~= d_end_lnum or d_end_col ~= 0)
    end
    diagnostics = vim.tbl_filter(line_filter, diagnostics)
  elseif scope == 'cursor' then
    -- If `col` is past the end of the line, show if the cursor is on the last char in the line
    local line_length = #api.nvim_buf_get_lines(bufnr, lnum, lnum + 1, true)[1]
    --- @param diagnostic vim.Diagnostic
    local function cursor_filter(diagnostic)
      local d_lnum, d_col, d_end_lnum, d_end_col = shared.get_logical_pos(diagnostic)
      return lnum >= d_lnum
        and lnum <= d_end_lnum
        and (lnum ~= d_lnum or col >= math.min(d_col, line_length - 1))
        and ((d_lnum == d_end_lnum and d_col == d_end_col) or lnum ~= d_end_lnum or col < d_end_col)
    end
    diagnostics = vim.tbl_filter(cursor_filter, diagnostics)
  end

  if vim.tbl_isempty(diagnostics) then
    return
  end

  local severity_sort = if_nil(opts.severity_sort, global_opts.severity_sort)
  if severity_sort then
    if type(severity_sort) == 'table' and severity_sort.reverse then
      table.sort(diagnostics, function(a, b)
        return shared.diagnostic_cmp(a, b, 'severity', true)
      end)
    else
      table.sort(diagnostics, function(a, b)
        return shared.diagnostic_cmp(a, b, 'severity', false)
      end)
    end
  end

  local lines = {} --- @type string[]
  local highlights = {} --- @type { hlname: string, prefix?: { length: integer, hlname: string? }, suffix?: { length: integer, hlname: string? } }[]
  local header = if_nil(opts.header, 'Diagnostics:')
  if header then
    vim.validate('header', header, { 'string', 'table' }, "'string' or 'table'")
    if type(header) == 'table' then
      -- Don't insert any lines for an empty string
      if #(header[1] or '') > 0 then
        lines[#lines + 1] = header[1]
        highlights[#highlights + 1] = { hlname = header[2] or 'Bold' }
      end
    elseif #header > 0 then
      lines[#lines + 1] = header
      highlights[#highlights + 1] = { hlname = 'Bold' }
    end
  end

  if opts.format then
    diagnostics = shared.reformat_diagnostics(opts.format, diagnostics)
  end

  if opts.source and (opts.source ~= 'if_many' or shared.count_sources(bufnr) > 1) then
    diagnostics = shared.prefix_source(diagnostics)
  end

  local prefix_opt = opts.prefix
    or (scope == 'cursor' and #diagnostics <= 1) and ''
    or function(_, i)
      return string.format('%d. ', i)
    end

  local prefix, prefix_hl_group --- @type string?, string?
  if prefix_opt then
    vim.validate(
      'prefix',
      prefix_opt,
      { 'string', 'table', 'function' },
      "'string' or 'table' or 'function'"
    )
    if type(prefix_opt) == 'string' then
      prefix, prefix_hl_group = prefix_opt, 'NormalFloat'
    elseif type(prefix_opt) == 'table' then
      prefix, prefix_hl_group = prefix_opt[1] or '', prefix_opt[2] or 'NormalFloat'
    end
  end

  local suffix_opt = opts.suffix
    or function(diagnostic)
      return diagnostic.code and string.format(' [%s]', diagnostic.code) or ''
    end

  local suffix, suffix_hl_group --- @type string?, string?
  if suffix_opt then
    vim.validate(
      'suffix',
      suffix_opt,
      { 'string', 'table', 'function' },
      "'string' or 'table' or 'function'"
    )
    if type(suffix_opt) == 'string' then
      suffix, suffix_hl_group = suffix_opt, 'NormalFloat'
    elseif type(suffix_opt) == 'table' then
      suffix, suffix_hl_group = suffix_opt[1] or '', suffix_opt[2] or 'NormalFloat'
    end
  end

  local related_info_locations = {} --- @type table<integer, lsp.Location>
  for i, diagnostic in ipairs(diagnostics) do
    if type(prefix_opt) == 'function' then
      local prefix0, prefix_hl_group0 = prefix_opt(diagnostic, i, #diagnostics)
      prefix, prefix_hl_group = prefix0 or '', prefix_hl_group0 or 'NormalFloat'
    end
    if type(suffix_opt) == 'function' then
      local suffix0, suffix_hl_group0 = suffix_opt(diagnostic, i, #diagnostics)
      suffix, suffix_hl_group = suffix0 or '', suffix_hl_group0 or 'NormalFloat'
    end

    local hiname = floating_highlight_map[diagnostic.severity]
    local message_lines = vim.split(diagnostic.message, '\n')
    local default_pre = string.rep(' ', #prefix)
    for j = 1, #message_lines do
      local pre = j == 1 and prefix or default_pre
      local suf = j == #message_lines and suffix or ''
      lines[#lines + 1] = pre .. message_lines[j] .. suf
      highlights[#highlights + 1] = {
        hlname = hiname,
        prefix = {
          length = j == 1 and #prefix or 0,
          hlname = prefix_hl_group,
        },
        suffix = {
          length = #suf,
          hlname = suffix_hl_group,
        },
      }
    end

    --- @type lsp.DiagnosticRelatedInformation[]
    local related_info = vim.tbl_get(diagnostic, 'user_data', 'lsp', 'relatedInformation') or {}
    -- Below the diagnostic, show its LSP related information (if any) in the form of file name and
    -- range, plus description.
    for _, info in ipairs(related_info) do
      local location = info.location
      local file_name = vim.fs.basename(vim.uri_to_fname(location.uri))
      local info_suffix = ': ' .. info.message
      related_info_locations[#lines + 1] = location
      lines[#lines + 1] = string.format(
        '%s%s:%s:%s%s',
        default_pre,
        file_name,
        location.range.start.line + 1,
        location.range.start.character + 1,
        info_suffix
      )
      highlights[#highlights + 1] = {
        hlname = '@string.special.path',
        prefix = {
          length = #default_pre,
          hlname = prefix_hl_group,
        },
        suffix = {
          length = #info_suffix,
          hlname = 'NormalFloat',
        },
      }
    end
  end

  -- Used by open_floating_preview to allow the float to be focused
  if not opts.focus_id then
    opts.focus_id = scope
  end

  --- @diagnostic disable-next-line: param-type-mismatch
  local float_bufnr, winnr = vim.lsp.util.open_floating_preview(lines, 'plaintext', opts)
  vim.bo[float_bufnr].path = vim.bo[bufnr].path

  -- TODO: Handle this generally (like vim.ui.open()), rather than overriding gf.
  vim.keymap.set('n', 'gf', function()
    local cursor_row = api.nvim_win_get_cursor(0)[1]
    local location = related_info_locations[cursor_row]
    if location then
      -- Split the window before calling `show_document` so the window doesn't disappear.
      vim.cmd.split()
      vim.lsp.util.show_document(location, 'utf-16', { focus = true })
    else
      vim.cmd.normal({ 'gf', bang = true })
    end
  end, { buf = float_bufnr, remap = false })

  --- @diagnostic disable-next-line: deprecated
  local add_highlight = api.nvim_buf_add_highlight

  for i, hl in ipairs(highlights) do
    local line = lines[i]
    local prefix_len = hl.prefix and hl.prefix.length or 0
    local suffix_len = hl.suffix and hl.suffix.length or 0
    if prefix_len > 0 then
      add_highlight(float_bufnr, -1, hl.prefix.hlname, i - 1, 0, prefix_len)
    end
    add_highlight(float_bufnr, -1, hl.hlname, i - 1, prefix_len, #line - suffix_len)
    if suffix_len > 0 then
      add_highlight(float_bufnr, -1, hl.suffix.hlname, i - 1, #line - suffix_len, -1)
    end
  end

  return float_bufnr, winnr
end

return M