Files
neovim/runtime/lua/vim/lsp/util.lua
Yi Ming b1c1f32089 refactor(pos,range): extract vim.pos._util
Problem:
- To share logic, creating a `vim.Range` currently creates two `vim.Pos` values
  as intermediates, which causes unnecessary table allocations.
- `pos.lua` and `range.lua` contain some overlapping logic.

Solution:
Add `vim.pos._util`, a module for handling
positions represented directly by `row` and `col`.
2026-05-20 16:23:03 +08:00

1972 lines
66 KiB
Lua

local protocol = require('vim.lsp.protocol')
local validate = vim.validate
local api = vim.api
local list_extend = vim.list_extend
local uv = vim.uv
local M = {}
--- @param border string|(string|[string,string])[]
local function border_error(border)
error(
string.format(
'invalid floating preview border: %s. :help vim.api.nvim_open_win()',
vim.inspect(border)
),
2
)
end
local border_size = {
none = { 0, 0 },
single = { 2, 2 },
double = { 2, 2 },
rounded = { 2, 2 },
solid = { 2, 2 },
shadow = { 1, 1 },
bold = { 2, 2 },
}
--- Check the border given by opts or the default border for the additional
--- size it adds to a float.
--- @param opts? {border:string|(string|[string,string])[]}
--- @return integer height
--- @return integer width
local function get_border_size(opts)
local border = opts and opts.border or vim.o.winborder
if border == '' then
border = 'none'
end
-- Convert winborder string option with custom characters into a table
if type(border) == 'string' and border:find(',') then
border = vim.split(border, ',')
end
if type(border) == 'string' then
if not border_size[border] then
border_error(border)
end
local r = border_size[border]
return r[1], r[2]
end
if 8 % #border ~= 0 then
border_error(border)
end
--- @param id integer
--- @return string
local function elem(id)
id = (id - 1) % #border + 1
local e = border[id]
if type(e) == 'table' then
-- border specified as a table of <character, highlight group>
return e[1]
elseif type(e) == 'string' then
-- border specified as a list of border characters
return e
end
--- @diagnostic disable-next-line:missing-return
border_error(border)
end
--- @param e string
--- @return integer
local function border_height(e)
return #e > 0 and 1 or 0
end
local top, bottom = elem(2), elem(6)
local height = border_height(top) + border_height(bottom)
local right, left = elem(4), elem(8)
local width = vim.fn.strdisplaywidth(right) + vim.fn.strdisplaywidth(left)
return height, width
end
--- Splits string at newlines, optionally removing unwanted blank lines.
---
--- @param s string Multiline string
--- @param no_blank boolean? Drop blank lines for each @param/@return (except one empty line
--- separating each). Workaround for https://github.com/LuaLS/lua-language-server/issues/2333
local function split_lines(s, no_blank)
s = string.gsub(s, '\r\n?', '\n')
local lines = {}
local in_desc = true -- Main description block, before seeing any @foo.
for line in vim.gsplit(s, '\n', { plain = true, trimempty = true }) do
local start_annotation = not not line:find('^ ?%@.?[pr]')
in_desc = (not start_annotation) and in_desc or false
if start_annotation and no_blank and not (lines[#lines] or ''):find('^%s*$') then
table.insert(lines, '') -- Separate each @foo with a blank line.
end
if in_desc or not no_blank or not line:find('^%s*$') then
table.insert(lines, line)
end
end
return lines
end
local function create_window_without_focus()
local prev = api.nvim_get_current_win()
vim.cmd.new()
local new = api.nvim_get_current_win()
api.nvim_set_current_win(prev)
return new
end
--- @param fn fun(x:any):any[]
--- @return function
local function sort_by_key(fn)
return function(a, b)
local ka, kb = fn(a), fn(b)
assert(#ka == #kb)
for i = 1, #ka do
if ka[i] ~= kb[i] then
return ka[i] < kb[i]
end
end
-- every value must have been equal here, which means it's not less than.
return false
end
end
-- TODO(ofseed): remove these exported functions by replacing their usages with `vim.pos`.
local get_lines = require('vim.pos._util').get_lines
local get_line = require('vim.pos._util').get_line
--- Applies a list of text edits to a buffer. Note: this mutates `text_edits` (sorts in-place and
--- adds `_index` fields).
---
---@param text_edits (lsp.TextEdit|lsp.AnnotatedTextEdit)[]
---@param bufnr integer Buffer id
---@param position_encoding 'utf-8'|'utf-16'|'utf-32'
---@param change_annotations? table<string, lsp.ChangeAnnotation>
---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textEdit
function M.apply_text_edits(text_edits, bufnr, position_encoding, change_annotations)
validate('text_edits', text_edits, 'table', false)
validate('bufnr', bufnr, 'number', false)
validate('position_encoding', position_encoding, 'string', false)
validate('change_annotations', change_annotations, 'table', true)
if not next(text_edits) then
return
end
assert(bufnr ~= 0, 'Explicit buffer number is required')
if not api.nvim_buf_is_loaded(bufnr) then
vim.fn.bufload(bufnr)
end
local marks = {} --- @type table<string,[integer,integer]>
local has_eol_text_edit = false
local function apply_text_edits()
-- Fix reversed range and indexing each text_edits
for index, text_edit in ipairs(text_edits) do
--- @cast text_edit lsp.TextEdit|{_index: integer}
-- XXX: Preserve existing _index to avoid surprises if the same edit is reapplied. #39344
if text_edit._index == nil then
text_edit._index = index
end
if
text_edit.range.start.line > text_edit.range['end'].line
or text_edit.range.start.line == text_edit.range['end'].line
and text_edit.range.start.character > text_edit.range['end'].character
then
local start = text_edit.range.start
text_edit.range.start = text_edit.range['end']
text_edit.range['end'] = start
end
end
--- @cast text_edits (lsp.TextEdit|lsp.AnnotatedTextEdit|{_index: integer})[]
-- Sort text_edits
---@param a (lsp.TextEdit|lsp.AnnotatedTextEdit|{_index: integer})
---@param b (lsp.TextEdit|lsp.AnnotatedTextEdit|{_index: integer})
---@return boolean
table.sort(text_edits, function(a, b)
if a.range.start.line ~= b.range.start.line then
return a.range.start.line > b.range.start.line
end
if a.range.start.character ~= b.range.start.character then
return a.range.start.character > b.range.start.character
end
return a._index > b._index
end)
-- save and restore local marks since they get deleted by nvim_buf_set_lines
for _, m in pairs(vim.fn.getmarklist(bufnr)) do
if m.mark:match("^'[a-z]$") then
marks[m.mark:sub(2, 2)] = { m.pos[2], m.pos[3] - 1 } -- api-indexed
end
end
for _, text_edit in ipairs(text_edits) do
-- Normalize line ending
text_edit.newText, _ = string.gsub(text_edit.newText, '\r\n?', '\n')
-- Convert from LSP style ranges to Neovim style ranges.
local range = vim.range.lsp(bufnr, text_edit.range, position_encoding)
local start_row, start_col, end_row, end_col =
range.start_row, range.start_col, range.end_row, range.end_col
local text = vim.split(text_edit.newText, '\n', { plain = true })
local max = api.nvim_buf_line_count(bufnr)
-- If the whole edit is after the lines in the buffer we can simply add the new text to the end
-- of the buffer.
if max <= start_row then
api.nvim_buf_set_lines(bufnr, max, max, false, text)
else
local last_line_len = #(get_line(bufnr, math.min(end_row, max - 1)) or '')
-- Some LSP servers may return +1 range of the buffer content but nvim_buf_set_text can't
-- accept it so we should fix it here.
if max <= end_row then
end_row = max - 1
end_col = last_line_len
has_eol_text_edit = true
else
-- If the replacement is over the end of a line (i.e. end_col is equal to the line length and the
-- replacement text ends with a newline We can likely assume that the replacement is assumed
-- to be meant to replace the newline with another newline and we need to make sure this
-- doesn't add an extra empty line. E.g. when the last line to be replaced contains a '\r'
-- in the file some servers (clangd on windows) will include that character in the line
-- while nvim_buf_set_text doesn't count it as part of the line.
if
end_col >= last_line_len
and text_edit.range['end'].character > end_col
and #text_edit.newText > 0
and string.sub(text_edit.newText, -1) == '\n'
then
table.remove(text, #text)
end
end
-- Make sure we don't go out of bounds for end_col
end_col = math.min(last_line_len, end_col)
api.nvim_buf_set_text(bufnr, start_row, start_col, end_row, end_col, text)
end
end
end
--- Track how many times each change annotation is applied to build up the final description.
---@type table<string, integer>
local change_count = {}
-- If there are any annotated text edits, we need to confirm them before applying the edits.
local confirmations = {} ---@type table<string, integer>
for _, text_edit in ipairs(text_edits) do
if text_edit.annotationId then
assert(
change_annotations ~= nil,
'change_annotations must be provided for annotated text edits'
)
local annotation = assert(
change_annotations[text_edit.annotationId],
string.format('No change annotation found for ID: %s', text_edit.annotationId)
)
if annotation.needsConfirmation then
confirmations[text_edit.annotationId] = (confirmations[text_edit.annotationId] or 0) + 1
end
change_count[text_edit.annotationId] = (change_count[text_edit.annotationId] or 0) + 1
end
end
if next(confirmations) then
local message = { 'Apply all changes?' }
for id, count in pairs(confirmations) do
local annotation = assert(change_annotations)[id]
message[#message + 1] = annotation.label
.. (annotation.description and (string.format(': %s', annotation.description)) or '')
.. (count > 1 and string.format(' (%d)', count) or '')
end
local response = vim.fn.confirm(table.concat(message, '\n'), '&Yes\n&No', 1, 'Question')
if response == 1 then
-- Proceed with applying text edits.
apply_text_edits()
else
-- Don't apply any text edits.
return
end
else
-- No confirmations needed, apply text edits directly.
apply_text_edits()
end
if change_annotations ~= nil and next(change_count) then
local change_message = { 'Applied changes:' }
for id, count in pairs(change_count) do
local annotation = change_annotations[id]
change_message[#change_message + 1] = annotation.label
.. (annotation.description and (': ' .. annotation.description) or '')
.. (count > 1 and string.format(' (%d)', count) or '')
end
vim.notify(table.concat(change_message, '\n'), vim.log.levels.INFO)
end
local max = api.nvim_buf_line_count(bufnr)
-- no need to restore marks that still exist
for _, m in pairs(vim.fn.getmarklist(bufnr)) do
marks[m.mark:sub(2, 2)] = nil
end
-- restore marks
for mark, pos in pairs(marks) do
if pos then
-- make sure we don't go out of bounds
pos[1] = math.min(pos[1], max)
pos[2] = math.min(pos[2], #(get_line(bufnr, pos[1] - 1) or ''))
api.nvim_buf_set_mark(bufnr or 0, mark, pos[1], pos[2], {})
end
end
-- Remove final line if needed
local fix_eol = has_eol_text_edit
fix_eol = fix_eol and (vim.bo[bufnr].eol or (vim.bo[bufnr].fixeol and not vim.bo[bufnr].binary))
fix_eol = fix_eol and get_line(bufnr, max - 1) == ''
if fix_eol then
api.nvim_buf_set_lines(bufnr, -2, -1, false, {})
end
end
--- Applies a `TextDocumentEdit`, which is a list of changes to a single
--- document.
---
---@param text_document_edit lsp.TextDocumentEdit
---@param index? integer: Optional index of the edit, if from a list of edits (or nil, if not from a list)
---@param position_encoding 'utf-8'|'utf-16'|'utf-32'
---@param change_annotations? table<string, lsp.ChangeAnnotation>
---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocumentEdit
function M.apply_text_document_edit(
text_document_edit,
index,
position_encoding,
change_annotations
)
vim.validate('position_encoding', position_encoding, 'string')
local text_document = text_document_edit.textDocument
local bufnr = vim.uri_to_bufnr(text_document.uri)
-- `VersionedTextDocumentIdentifier`s version may be null
-- https://microsoft.github.io/language-server-protocol/specification#versionedTextDocumentIdentifier
if
-- For lists of text document edits,
-- do not check the version after the first edit.
not (index and index > 1)
and (
text_document.version ~= vim.NIL
and text_document.version > 0
and M.buf_versions[bufnr] > text_document.version
)
then
print('Buffer ', text_document.uri, ' newer than edits.')
return
end
M.apply_text_edits(text_document_edit.edits, bufnr, position_encoding, change_annotations)
end
local function path_components(path)
return vim.split(path, '/', { plain = true })
end
--- @param path string[]
--- @param prefix string[]
--- @return boolean
local function path_under_prefix(path, prefix)
for i, c in ipairs(prefix) do
if c ~= path[i] then
return false
end
end
return true
end
--- Get list of loaded writable buffers whose filename matches the given path
--- prefix (normalized full path).
---@param prefix string
---@return integer[]
local function get_writable_bufs(prefix)
local prefix_parts = path_components(prefix)
local buffers = {} --- @type integer[]
for _, buf in ipairs(api.nvim_list_bufs()) do
-- No need to care about unloaded or nofile buffers. Also :saveas won't work for them.
if
api.nvim_buf_is_loaded(buf)
and not vim.list_contains({ 'nofile', 'nowrite' }, vim.bo[buf].buftype)
then
local bname = api.nvim_buf_get_name(buf)
local path = path_components(vim.fs.normalize(bname, { expand_env = false }))
if path_under_prefix(path, prefix_parts) then
buffers[#buffers + 1] = buf
end
end
end
return buffers
end
local function escape_gsub_repl(s)
return (s:gsub('%%', '%%%%'))
end
--- @class vim.lsp.util.rename.Opts
--- @inlinedoc
--- @field overwrite? boolean
--- @field ignoreIfExists? boolean
--- Rename old_fname to new_fname
---
--- Existing buffers are renamed as well, while maintaining their bufnr.
---
--- It deletes existing buffers that conflict with the renamed file name only when
--- * `opts` requests overwriting; or
--- * the conflicting buffers are not loaded, so that deleting them does not result in data loss.
---
--- @param old_fname string
--- @param new_fname string
--- @param opts? vim.lsp.util.rename.Opts Options:
function M.rename(old_fname, new_fname, opts)
opts = opts or {}
local skip = not opts.overwrite or opts.ignoreIfExists
local old_fname_full = uv.fs_realpath(vim.fs.normalize(old_fname, { expand_env = false }))
if not old_fname_full then
vim.notify('Invalid path: ' .. old_fname, vim.log.levels.ERROR)
return
end
local target_exists = uv.fs_stat(new_fname) ~= nil
if target_exists and skip then
vim.notify(new_fname .. ' already exists. Skipping rename.', vim.log.levels.ERROR)
return
end
local buf_rename = {} ---@type table<integer, {from: string, to: string}>
local old_fname_pat = '^' .. vim.pesc(old_fname_full)
for _, b in ipairs(get_writable_bufs(old_fname_full)) do
-- Renaming a buffer may conflict with another buffer that happens to have the same name. In
-- most cases, this would have been already detected by the file conflict check above, but the
-- conflicting buffer may not be associated with a file. For example, 'buftype' can be "nofile"
-- or "nowrite", or the buffer can be a normal buffer but has not been written to the file yet.
-- Renaming should fail in such cases to avoid losing the contents of the conflicting buffer.
local old_bname = api.nvim_buf_get_name(b)
local new_bname = old_bname:gsub(old_fname_pat, escape_gsub_repl(new_fname))
if vim.fn.bufexists(new_bname) == 1 then
local existing_buf = vim.fn.bufnr(new_bname)
if api.nvim_buf_is_loaded(existing_buf) and skip then
vim.notify(
new_bname .. ' already exists in the buffer list. Skipping rename.',
vim.log.levels.ERROR
)
return
end
-- no need to preserve if such a buffer is empty
api.nvim_buf_delete(existing_buf, {})
end
buf_rename[b] = { from = old_bname, to = new_bname }
end
local newdir = vim.fs.dirname(new_fname)
vim.fn.mkdir(newdir, 'p')
assert(os.rename(old_fname_full, new_fname))
local old_undofile = vim.fn.undofile(old_fname_full)
if uv.fs_stat(old_undofile) ~= nil then
local new_undofile = vim.fn.undofile(new_fname)
vim.fn.mkdir(vim.fs.dirname(new_undofile), 'p')
os.rename(old_undofile, new_undofile)
end
for b, rename in pairs(buf_rename) do
-- Rename with :saveas. This does two things:
-- * Unset BF_WRITE_MASK, so that users don't get E13 when they do :write.
-- * Send didClose and didOpen via textDocument/didSave handler.
vim._with({ buf = b }, function()
vim.cmd('keepalt saveas! ' .. vim.fn.fnameescape(rename.to))
end)
-- Delete the new buffer with the old name created by :saveas. nvim_buf_delete and
-- :bwipeout are futile because the buffer will be added again somewhere else.
vim.cmd('bdelete! ' .. vim.fn.bufnr(rename.from))
end
end
--- @param change lsp.CreateFile
local function create_file(change)
local opts = change.options or {}
-- from spec: Overwrite wins over `ignoreIfExists`
local fname = vim.uri_to_fname(change.uri)
if not opts.ignoreIfExists or opts.overwrite then
vim.fn.mkdir(vim.fs.dirname(fname), 'p')
local file = io.open(fname, 'w')
if file then
file:close()
end
end
vim.fn.bufadd(fname)
end
--- @param change lsp.DeleteFile
local function delete_file(change)
local opts = change.options or {}
local fname = vim.uri_to_fname(change.uri)
local bufnr = vim.fn.bufadd(fname)
vim.fs.rm(fname, {
force = opts.ignoreIfNotExists,
recursive = opts.recursive,
})
api.nvim_buf_delete(bufnr, { force = true })
end
--- Applies a `WorkspaceEdit`.
---
---@param workspace_edit lsp.WorkspaceEdit
---@param position_encoding 'utf-8'|'utf-16'|'utf-32' (required)
---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#workspace_applyEdit
function M.apply_workspace_edit(workspace_edit, position_encoding)
vim.validate('position_encoding', position_encoding, 'string')
if workspace_edit.documentChanges then
for idx, change in ipairs(workspace_edit.documentChanges) do
if change.kind == 'rename' then
local options = change.options --[[@as vim.lsp.util.rename.Opts]]
M.rename(vim.uri_to_fname(change.oldUri), vim.uri_to_fname(change.newUri), options)
elseif change.kind == 'create' then
create_file(change)
elseif change.kind == 'delete' then
delete_file(change)
elseif change.kind then --- @diagnostic disable-line:undefined-field
error(string.format('Unsupported change: %q', vim.inspect(change)))
else
local bufnr = vim.uri_to_bufnr(change.textDocument.uri)
M.apply_text_document_edit(change, idx, position_encoding, workspace_edit.changeAnnotations)
-- avoid triggering OptionSet
if not vim.bo[bufnr].buflisted then
vim.bo[bufnr].buflisted = true
end
end
end
return
end
local all_changes = workspace_edit.changes
if not (all_changes and not vim.tbl_isempty(all_changes)) then
return
end
for uri, changes in pairs(all_changes) do
local bufnr = vim.uri_to_bufnr(uri)
M.apply_text_edits(changes, bufnr, position_encoding, workspace_edit.changeAnnotations)
-- avoid triggering OptionSet
if not vim.bo[bufnr].buflisted then
vim.bo[bufnr].buflisted = true
end
end
end
--- Converts any of `MarkedString` | `MarkedString[]` | `MarkupContent` into
--- a list of lines containing valid markdown. Useful to populate the hover
--- window for `textDocument/hover`, for parsing the result of
--- `textDocument/signatureHelp`, and potentially others.
---
--- Note that if the input is of type `MarkupContent` and its kind is `plaintext`,
--- then the corresponding value is returned without further modifications.
---
---@param input lsp.MarkedString|lsp.MarkedString[]|lsp.MarkupContent
---@param contents string[]? List of strings to extend with converted lines. Defaults to {}.
---@return string[] extended with lines of converted markdown.
---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_hover
function M.convert_input_to_markdown_lines(input, contents)
contents = contents or {}
-- MarkedString variation 1
if type(input) == 'string' then
list_extend(contents, split_lines(input, true))
else
assert(type(input) == 'table', 'Expected a table for LSP input')
-- MarkupContent
if input.kind then
local value = input.value or ''
list_extend(contents, split_lines(value, true))
-- MarkupString variation 2
elseif input.language then
table.insert(contents, '```' .. input.language)
list_extend(contents, split_lines(input.value or ''))
table.insert(contents, '```')
-- By deduction, this must be MarkedString[]
else
-- Use our existing logic to handle MarkedString
for _, marked_string in ipairs(input) do
M.convert_input_to_markdown_lines(marked_string, contents)
end
end
end
if (contents[1] == '' or contents[1] == nil) and #contents == 1 then
return {}
end
return contents
end
--- Returns the line/column-based position in `contents` at the given offset.
---
---@param offset integer
---@param contents string[]
---@return { [1]: integer, [2]: integer }?
local function get_pos_from_offset(offset, contents)
local i = 0
for l, line in ipairs(contents) do
if offset >= i and offset < i + #line then
return { l - 1, offset - i + 1 }
else
i = i + #line + 1
end
end
end
--- Converts `textDocument/signatureHelp` response to markdown lines.
---
---@param signature_help lsp.SignatureHelp Response of `textDocument/SignatureHelp`
---@param ft string? filetype that will be use as the `lang` for the label markdown code block
---@param triggers string[]? list of trigger characters from the lsp server. used to better determine parameter offsets
---@return string[]? # lines of converted markdown.
---@return Range4? # highlight range for the active parameter
---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_signatureHelp
function M.convert_signature_help_to_markdown_lines(signature_help, ft, triggers)
--The active signature. If omitted or the value lies outside the range of
--`signatures` the value defaults to zero or is ignored if `signatures.length == 0`.
--Whenever possible implementors should make an active decision about
--the active signature and shouldn't rely on a default value.
local contents = {} --- @type string[]
local active_offset ---@type [integer, integer]?
local active_signature = signature_help.activeSignature or 0
-- If the activeSignature is not inside the valid range, then clip it.
-- In 3.15 of the protocol, activeSignature was allowed to be negative
if active_signature >= #signature_help.signatures or active_signature < 0 then
active_signature = 0
end
local signature = vim.deepcopy(signature_help.signatures[active_signature + 1])
local label = signature.label
if ft then
-- wrap inside a code block for proper rendering
label = ('```%s\n%s\n```'):format(ft, label)
end
list_extend(contents, vim.split(label, '\n', { plain = true, trimempty = true }))
local doc = signature.documentation
if doc then
-- if LSP returns plain string, we treat it as plaintext. This avoids
-- special characters like underscore or similar from being interpreted
-- as markdown font modifiers
if type(doc) == 'string' then
signature.documentation = { kind = 'plaintext', value = doc }
end
-- Add delimiter if there is documentation to display
if signature.documentation.value ~= '' then
contents[#contents + 1] = '---'
end
M.convert_input_to_markdown_lines(signature.documentation, contents)
end
if signature.parameters and #signature.parameters > 0 then
local active_parameter = signature.activeParameter or signature_help.activeParameter
-- NOTE: We intentionally violate the LSP spec, which states that if `activeParameter`
-- is not provided or is out-of-bounds, it should default to 0.
-- Instead, we default to `nil`, as most clients do. In practice, 'no active parameter'
-- is better default than 'first parameter' and aligns better with user expectations.
-- Related discussion: https://github.com/microsoft/language-server-protocol/issues/1271
if
not active_parameter
or active_parameter == vim.NIL
or active_parameter < 0
or active_parameter >= #signature.parameters
then
return contents, nil
end
local parameter = signature.parameters[active_parameter + 1]
local parameter_label = parameter.label
if type(parameter_label) == 'table' then
active_offset = parameter_label
else
local offset = 1 ---@type integer?
-- try to set the initial offset to the first found trigger character
for _, t in ipairs(triggers or {}) do
local trigger_offset = signature.label:find(t, 1, true)
if trigger_offset and (offset == 1 or trigger_offset < offset) then
offset = trigger_offset
end
end
for p, param in pairs(signature.parameters) do
local plabel = param.label
assert(type(plabel) == 'string', 'Expected label to be a string')
offset = signature.label:find(plabel, offset, true)
if not offset then
break
end
if p == active_parameter + 1 then
active_offset = { offset - 1, offset + #parameter_label - 1 }
break
end
offset = offset + #plabel + 1
end
end
if parameter.documentation then
M.convert_input_to_markdown_lines(parameter.documentation, contents)
end
end
local active_hl = nil
if active_offset then
-- Account for the start of the markdown block.
if ft then
active_offset[1] = active_offset[1] + #contents[1]
active_offset[2] = active_offset[2] + #contents[1]
end
local a_start = get_pos_from_offset(active_offset[1], contents)
local a_end = get_pos_from_offset(active_offset[2], contents)
if a_start and a_end then
active_hl = { a_start[1], a_start[2], a_end[1], a_end[2] }
end
end
return contents, active_hl
end
--- Creates a table with sensible default options for a floating window. The
--- table can be passed to |nvim_open_win()|.
---
---@param width integer window width (in character cells)
---@param height integer window height (in character cells)
---@param opts? vim.lsp.util.open_floating_preview.Opts
---@return vim.api.keyset.win_config
function M.make_floating_popup_options(width, height, opts)
validate('opts', opts, 'table', true)
opts = opts or {}
validate('opts.offset_x', opts.offset_x, 'number', true)
validate('opts.offset_y', opts.offset_y, 'number', true)
local anchor = ''
local lines_above = vim.fn.winline() - 1
local lines_below = vim.fn.winheight(0) - lines_above
if opts.relative == 'mouse' then
lines_above = vim.fn.getmousepos().line - 1
lines_below = vim.fn.winheight(0) - lines_above
elseif opts.relative == 'editor' then
-- No cursor to anchor against; treat the whole editor as space below.
lines_above = 0
lines_below = vim.o.lines
end
local anchor_bias = opts.anchor_bias or 'auto'
local anchor_below --- @type boolean?
if anchor_bias == 'below' then
anchor_below = (lines_below > lines_above) or (height <= lines_below)
elseif anchor_bias == 'above' then
local anchor_above = (lines_above > lines_below) or (height <= lines_above)
anchor_below = not anchor_above
else
anchor_below = lines_below > lines_above
end
local border_height = get_border_size(opts)
local row, col --- @type integer?, integer?
if anchor_below then
anchor = anchor .. 'N'
height = math.max(math.min(lines_below - border_height, height), 0)
row = 1
else
anchor = anchor .. 'S'
height = math.max(math.min(lines_above - border_height, height), 0)
row = 0
end
local wincol = vim.fn.wincol()
if opts.relative == 'mouse' then
wincol = vim.fn.getmousepos().column
elseif opts.relative == 'editor' then
wincol = 0
end
if wincol + width + (opts.offset_x or 0) <= vim.o.columns then
anchor = anchor .. 'W'
col = 0
else
anchor = anchor .. 'E'
col = 1
end
local title = ((opts.border or vim.o.winborder ~= '') and opts.title) and opts.title or nil
local title_pos --- @type 'left'|'center'|'right'?
if title then
title_pos = opts.title_pos or 'center'
end
return {
anchor = anchor,
row = row + (opts.offset_y or 0),
col = col + (opts.offset_x or 0),
height = height,
focusable = opts.focusable,
relative = (opts.relative == 'mouse' or opts.relative == 'editor') and opts.relative
or 'cursor',
style = 'minimal',
width = width,
border = opts.border,
zindex = opts.zindex or (api.nvim_win_get_config(0).zindex or 49) + 1,
title = title,
title_pos = title_pos,
}
end
--- @class vim.lsp.util.show_document.Opts
--- @inlinedoc
---
--- Jump to existing window if buffer is already open.
--- @field reuse_win? boolean
---
--- Whether to focus/jump to location if possible.
--- (defaults: true)
--- @field focus? boolean
--- Shows document and optionally jumps to the location.
---
---@param location lsp.Location|lsp.LocationLink
---@param position_encoding 'utf-8'|'utf-16'|'utf-32'
---@param opts? vim.lsp.util.show_document.Opts
---@return boolean `true` if succeeded
function M.show_document(location, position_encoding, opts)
vim.validate('position_encoding', position_encoding, 'string')
-- location may be Location or LocationLink
local uri = location.uri or location.targetUri
if uri == nil then
return false
end
local bufnr = vim.uri_to_bufnr(uri)
opts = opts or {}
local focus = vim.nonnil(opts.focus, true)
if focus then
-- Save position in jumplist
vim.cmd("normal! m'")
-- Push a new item into tagstack
local from = { vim.fn.bufnr('%'), vim.fn.line('.'), vim.fn.col('.'), 0 }
local items = { { tagname = vim.fn.expand('<cword>'), from = from } }
vim.fn.settagstack(vim.fn.win_getid(), { items = items }, 't')
end
local win = opts.reuse_win and vim.fn.win_findbuf(bufnr)[1]
or focus and api.nvim_get_current_win()
or create_window_without_focus()
vim.bo[bufnr].buflisted = true
api.nvim_win_set_buf(win, bufnr)
if focus then
api.nvim_set_current_win(win)
end
-- location may be Location or LocationLink
local range = location.range or location.targetSelectionRange
if range then
-- Jump to new location (adjusting for encoding of characters)
local pos = vim.pos.lsp(bufnr, range.start, position_encoding)
local row, col = pos.row, pos.col
api.nvim_win_set_cursor(win, { row + 1, col })
vim._with({ win = win }, function()
-- Open folds under the cursor
vim.cmd('normal! zv')
end)
-- nvim_win_set_cursor clamps to last char at EOL. In insert mode the cursor
-- should be past the last char (append position).
if vim.api.nvim_get_mode().mode == 'i' then
local line = api.nvim_buf_get_lines(bufnr, row, row + 1, false)[1] or ''
if col >= #line then
vim.api.nvim_feedkeys(vim.keycode('<End>'), 'n', false)
end
end
end
return true
end
--- Previews a location in a floating window
---
--- behavior depends on type of location:
--- - for Location, range is shown (e.g., function definition)
--- - for LocationLink, targetRange is shown (e.g., body of function definition)
---
---@param location lsp.Location|lsp.LocationLink
---@param opts? vim.lsp.util.open_floating_preview.Opts
---@return integer? buffer id of float window
---@return integer? window id of float window
function M.preview_location(location, opts)
-- location may be LocationLink or Location (more useful for the former)
local uri = location.targetUri or location.uri
if uri == nil then
return
end
local bufnr = vim.uri_to_bufnr(uri)
if not api.nvim_buf_is_loaded(bufnr) then
vim.fn.bufload(bufnr)
end
local range = location.targetRange or location.range
local contents = api.nvim_buf_get_lines(bufnr, range.start.line, range['end'].line + 1, false)
local syntax = vim.bo[bufnr].syntax
if syntax == '' then
-- When no syntax is set, we use filetype as fallback. This might not result
-- in a valid syntax definition.
-- An empty syntax is more common now with TreeSitter, since TS disables syntax.
syntax = vim.bo[bufnr].filetype
end
opts = opts or {}
opts.focus_id = 'location'
return M.open_floating_preview(contents, syntax, opts)
end
local function find_window_by_var(name, value)
for _, win in ipairs(api.nvim_list_wins()) do
if vim.w[win][name] == value then
return win
end
end
end
---Returns true if the line is empty or only contains whitespace.
---@param line string
---@return boolean
local function is_blank_line(line)
return line and line:match('^%s*$')
end
---Returns true if the line corresponds to a Markdown thematic break.
---@see https://github.github.com/gfm/#thematic-break
---@param line string
---@return boolean
local function is_separator_line(line)
local i = 1
-- 1. Skip up to 3 leading spaces
local leading_spaces = 3
while i <= #line and line:byte(i) == string.byte(' ') and leading_spaces > 0 do
i = i + 1
leading_spaces = leading_spaces - 1
end
-- 2. Determine the delimiter character
local delimiter = line:byte(i) -- nil if i > #line
if
delimiter ~= string.byte('-')
and delimiter ~= string.byte('_')
and delimiter ~= string.byte('*')
then
return false
end
local ndelimiters = 1
i = i + 1
-- 3. Iterate until found non-whitespace or other than expected delimiter
while i <= #line do
local char = line:byte(i)
if char == delimiter then
ndelimiters = ndelimiters + 1
elseif not (char == string.byte(' ') or char == string.byte('\t')) then
return false
end
i = i + 1
end
return ndelimiters >= 3
end
---Replaces separator lines by the given divider and removing surrounding blank lines.
---@param contents string[]
---@param divider string
---@return string[]
local function replace_separators(contents, divider)
local trimmed = {}
local l = 1
while l <= #contents do
local line = contents[l]
if is_separator_line(line) then
if l > 1 and is_blank_line(contents[l - 1]) then
table.remove(trimmed)
end
table.insert(trimmed, divider)
if is_blank_line(contents[l + 1]) then
l = l + 1
end
else
table.insert(trimmed, line)
end
l = l + 1
end
return trimmed
end
---Collapses successive blank lines in the input table into a single one.
---@param contents string[]
---@return string[]
local function collapse_blank_lines(contents)
local collapsed = {}
local l = 1
while l <= #contents do
local line = contents[l]
if is_blank_line(line) then
while is_blank_line(contents[l + 1]) do
l = l + 1
end
end
table.insert(collapsed, line)
l = l + 1
end
return collapsed
end
local function get_markdown_fences()
local fences = {} --- @type table<string,string>
for _, fence in
pairs(vim.g.markdown_fenced_languages or {} --[[@as string[] ]])
do
local lang, syntax = fence:match('^(.*)=(.*)$')
if lang then
fences[lang] = syntax
end
end
return fences
end
--- @deprecated
--- Converts markdown into syntax highlighted regions by stripping the code
--- blocks and converting them into highlighted code.
--- This will by default insert a blank line separator after those code block
--- regions to improve readability.
---
--- This method configures the given buffer and returns the lines to set.
---
--- If you want to open a popup with fancy markdown, use `open_floating_preview` instead
---
---@param bufnr integer
---@param contents string[] of lines to show in window
---@param opts? table with optional fields
--- - height of floating window
--- - max_height maximal height of floating window
--- - max_width maximal width of floating window
--- - separator insert separator after code block
--- - width of floating window
--- - wrap_at character to wrap at for computing height
---@return table stripped content
function M.stylize_markdown(bufnr, contents, opts)
vim.deprecate('vim.lsp.util.stylize_markdown', nil, '0.14')
validate('contents', contents, 'table')
validate('opts', opts, 'table', true)
opts = opts or {}
-- table of fence types to {ft, begin, end}
-- when ft is nil, we get the ft from the regex match
local matchers = {
block = { nil, '```+%s*([a-zA-Z0-9_]*)', '```+' },
pre = { nil, '<pre>([a-z0-9]*)', '</pre>' },
code = { '', '<code>', '</code>' },
text = { 'text', '<text>', '</text>' },
}
--- @param line string
--- @return {type:string,ft:string}?
local function match_begin(line)
for type, pattern in pairs(matchers) do
--- @type string?
local ret = line:match(string.format('^%%s*%s%%s*$', pattern[2]))
if ret then
return {
type = type,
ft = pattern[1] or ret,
}
end
end
end
--- @param line string
--- @param match {type:string,ft:string}
--- @return string
local function match_end(line, match)
local pattern = matchers[match.type]
return line:match(string.format('^%%s*%s%%s*$', pattern[3]))
end
-- Clean up
contents = vim.split(table.concat(contents, '\n'), '\n', { trimempty = true })
local stripped = {} --- @type string[]
local highlights = {} --- @type {ft:string,start:integer,finish:integer}[]
local i = 1
while i <= #contents do
local line = contents[i]
local match = match_begin(line)
if match then
local start = #stripped
i = i + 1
while i <= #contents do
line = contents[i]
if match_end(line, match) then
i = i + 1
break
end
stripped[#stripped + 1] = line
i = i + 1
end
table.insert(highlights, {
ft = match.ft,
start = start + 1,
finish = #stripped,
})
-- add a separator, but not on the last line
if opts.separator and i < #contents then
stripped[#stripped + 1] = '---'
end
else
-- strip any empty lines or separators prior to this separator in actual markdown
if line:match('^---+$') then
while
stripped[#stripped]
and (stripped[#stripped]:match('^%s*$') or stripped[#stripped]:match('^---+$'))
do
stripped[#stripped] = nil
end
end
-- add the line if its not an empty line following a separator
if
not (line:match('^%s*$') and stripped[#stripped] and stripped[#stripped]:match('^---+$'))
then
stripped[#stripped + 1] = line
end
i = i + 1
end
end
-- Handle some common html escape sequences
--- @type string[]
stripped = vim.tbl_map(
--- @param line string
function(line)
local escapes = {
['&gt;'] = '>',
['&lt;'] = '<',
['&quot;'] = '"',
['&apos;'] = "'",
['&ensp;'] = ' ',
['&emsp;'] = ' ',
['&amp;'] = '&',
}
return (line:gsub('&[^ ;]+;', escapes))
end,
stripped
)
-- Compute size of float needed to show (wrapped) lines
opts.wrap_at = opts.wrap_at or (vim.wo['wrap'] and api.nvim_win_get_width(0))
local width = M._make_floating_popup_size(stripped, opts)
local sep_line = string.rep('', math.min(width, opts.wrap_at or width))
for l in ipairs(stripped) do
if stripped[l]:match('^---+$') then
stripped[l] = sep_line
end
end
api.nvim_buf_set_lines(bufnr, 0, -1, false, stripped)
local idx = 1
-- keep track of syntaxes we already included.
-- no need to include the same syntax more than once
local langs = {} --- @type table<string,boolean>
local fences = get_markdown_fences()
local function apply_syntax_to_region(ft, start, finish)
if ft == '' then
vim.cmd(
string.format(
'syntax region markdownCode start=+\\%%%dl+ end=+\\%%%dl+ keepend extend',
start,
finish + 1
)
)
return
end
ft = fences[ft] or ft
local name = ft .. idx
idx = idx + 1
local lang = '@' .. ft:upper()
if not langs[lang] then
-- HACK: reset current_syntax, since some syntax files like markdown won't load if it is already set
pcall(api.nvim_buf_del_var, bufnr, 'current_syntax')
if #api.nvim_get_runtime_file(('syntax/%s.vim'):format(ft), true) == 0 then
return
end
--- @diagnostic disable-next-line:param-type-mismatch
pcall(vim.cmd, string.format('syntax include %s syntax/%s.vim', lang, ft))
langs[lang] = true
end
vim.cmd(
string.format(
'syntax region %s start=+\\%%%dl+ end=+\\%%%dl+ contains=%s keepend',
name,
start,
finish + 1,
lang
)
)
end
-- needs to run in the buffer for the regions to work
vim._with({ buf = bufnr }, function()
-- we need to apply lsp_markdown regions speperately, since otherwise
-- markdown regions can "bleed" through the other syntax regions
-- and mess up the formatting
local last = 1
for _, h in ipairs(highlights) do
if last < h.start then
apply_syntax_to_region('lsp_markdown', last, h.start - 1)
end
apply_syntax_to_region(h.ft, h.start, h.finish)
last = h.finish + 1
end
if last <= #stripped then
apply_syntax_to_region('lsp_markdown', last, #stripped)
end
end)
return stripped
end
--- @class (private) vim.lsp.util._normalize_markdown.Opts
--- @field width integer Thematic breaks are expanded to this size. Defaults to 80.
--- Normalizes Markdown input to a canonical form.
---
--- The returned Markdown adheres to the GitHub Flavored Markdown (GFM)
--- specification, as required by the LSP.
---
--- The following transformations are made:
---
--- 1. Carriage returns ('\r') and empty lines at the beginning and end are removed
--- 2. Successive empty lines are collapsed into a single empty line
--- 3. Thematic breaks are expanded to the given width
---
---@param contents string[]
---@param opts? vim.lsp.util._normalize_markdown.Opts
---@return string[] table of lines containing normalized Markdown
---@see https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#markupContent
---@see https://github.github.com/gfm
function M._normalize_markdown(contents, opts)
validate('contents', contents, 'table')
validate('opts', opts, 'table', true)
opts = opts or {}
-- 1. Carriage returns are removed
contents = vim.split(table.concat(contents, '\n'):gsub('\r', ''), '\n', { trimempty = true })
-- 2. Successive empty lines are collapsed into a single empty line
contents = collapse_blank_lines(contents)
-- 3. Thematic breaks are expanded to the given width
local divider = string.rep('', opts.width or 80)
contents = replace_separators(contents, divider)
return contents
end
--- Closes the preview window
---
---@param winnr integer window id of preview window
---@param bufnrs table? optional list of ignored buffers
local function close_preview_window(winnr, bufnrs)
vim.schedule(function()
-- exit if we are in one of ignored buffers
if bufnrs and vim.list_contains(bufnrs, api.nvim_get_current_buf()) then
return
end
local augroup = 'nvim.preview_window_' .. winnr
pcall(api.nvim_del_augroup_by_name, augroup)
pcall(api.nvim_win_close, winnr, true)
end)
end
--- Creates autocommands to close a preview window when events happen.
---
---@param events table list of events
---@param winnr integer window id of preview window
---@param floating_bufnr integer floating preview buffer
---@param bufnr integer buffer that opened the floating preview buffer
---@see autocmd-events
local function close_preview_autocmd(events, winnr, floating_bufnr, bufnr)
local augroup = api.nvim_create_augroup('nvim.preview_window_' .. winnr, {
clear = true,
})
-- close the preview window when entered a buffer that is not
-- the floating window buffer or the buffer that spawned it
api.nvim_create_autocmd('BufLeave', {
group = augroup,
buf = bufnr,
callback = function()
vim.schedule(function()
-- When jumping to the quickfix window from the preview window,
-- do not close the preview window.
if api.nvim_get_option_value('filetype', { buf = 0 }) ~= 'qf' then
close_preview_window(winnr, { floating_bufnr, bufnr })
end
end)
end,
})
if #events > 0 then
api.nvim_create_autocmd(events, {
group = augroup,
buf = bufnr,
callback = function()
close_preview_window(winnr)
end,
})
end
end
--- Computes size of float needed to show contents (with optional wrapping)
---
---@param contents string[] of lines to show in window
---@param opts? vim.lsp.util.open_floating_preview.Opts
---@return integer width size of float
---@return integer height size of float
function M._make_floating_popup_size(contents, opts)
validate('contents', contents, 'table')
validate('opts', opts, 'table', true)
opts = opts or {}
local width = opts.width
local height = opts.height
local wrap_at = opts.wrap_at
local max_width = opts.max_width
local max_height = opts.max_height
local line_widths = {} --- @type table<integer,integer>
if not width then
width = 0
for i, line in ipairs(contents) do
-- TODO(ashkan) use nvim_strdisplaywidth if/when that is introduced.
line_widths[i] = vim.fn.strdisplaywidth(line:gsub('%z', '\n'))
width = math.max(line_widths[i], width)
end
end
local _, border_width = get_border_size(opts)
local screen_width = api.nvim_win_get_width(0)
width = math.min(width, screen_width)
-- make sure borders are always inside the screen
width = math.min(width, screen_width - border_width)
-- Make sure that the width is large enough to fit the title.
local title_length = 0
local chunks = type(opts.title) == 'string' and { { opts.title } } or opts.title or {}
for _, chunk in
ipairs(chunks --[=[@as [string, string][]]=])
do
title_length = title_length + vim.fn.strdisplaywidth(chunk[1])
end
width = math.max(width, title_length)
if wrap_at then
wrap_at = math.min(wrap_at, width)
end
if max_width then
width = math.min(width, max_width)
wrap_at = math.min(wrap_at or max_width, max_width)
end
if not height then
height = #contents
if wrap_at and width >= wrap_at then
height = 0
if vim.tbl_isempty(line_widths) then
for _, line in ipairs(contents) do
local line_width = vim.fn.strdisplaywidth(line:gsub('%z', '\n'))
height = height + math.max(1, math.ceil(line_width / wrap_at))
end
else
for i = 1, #contents do
height = height + math.max(1, math.ceil(line_widths[i] / wrap_at))
end
end
end
end
if max_height then
height = math.min(height, max_height)
end
return width, height
end
--- @class vim.lsp.util.open_floating_preview.Opts
---
--- Height of floating window
--- @field height? integer
---
--- Width of floating window
--- @field width? integer
---
--- Wrap long lines
--- (default: `true`)
--- @field wrap? boolean
---
--- Character to wrap at for computing height when wrap is enabled
--- @field wrap_at? integer
---
--- Maximal width of floating window
--- @field max_width? integer
---
--- Maximal height of floating window
--- @field max_height? integer
---
--- If a popup with this id is opened, then focus it
--- @field focus_id? string
---
--- List of events that closes the floating window
--- @field close_events? table
---
--- Make float focusable.
--- (default: `true`)
--- @field focusable? boolean
---
--- If `true`, and if {focusable} is also `true`, focus an existing floating
--- window with the same {focus_id}
--- (default: `true`)
--- @field focus? boolean
---
--- offset to add to `col`
--- @field offset_x? integer
---
--- offset to add to `row`
--- @field offset_y? integer
--- @field border? string|(string|[string,string])[] override `border`
--- @field zindex? integer override `zindex`, defaults to 50
--- @field title? string|[string,string][]
--- @field title_pos? 'left'|'center'|'right'
---
--- (default: `'cursor'`)
--- @field relative? 'mouse'|'cursor'|'editor'
---
--- Adjusts placement relative to cursor.
--- - "auto": place window based on which side of the cursor has more lines
--- - "above": place the window above the cursor unless there are not enough lines
--- to display the full window height.
--- - "below": place the window below the cursor unless there are not enough lines
--- to display the full window height.
--- (default: `'auto'`)
--- @field anchor_bias? 'auto'|'above'|'below'
---
--- @field _update_win? integer
--- Shows contents in a floating window.
---
---@param contents table of lines to show in window
---@param syntax string of syntax to set for opened buffer
---@param opts? vim.lsp.util.open_floating_preview.Opts with optional fields
--- (additional keys are filtered with |vim.lsp.util.make_floating_popup_options()|
--- before they are passed on to |nvim_open_win()|)
---@return integer bufnr of newly created float window
---@return integer winid of newly created float window preview window
function M.open_floating_preview(contents, syntax, opts)
validate('contents', contents, 'table')
validate('syntax', syntax, 'string', true)
validate('opts', opts, 'table', true)
opts = opts or {}
opts.wrap = opts.wrap ~= false -- wrapping by default
opts.focus = opts.focus ~= false
opts.close_events = opts.close_events or { 'CursorMoved', 'CursorMovedI', 'InsertCharPre' }
local bufnr = api.nvim_get_current_buf()
local floating_winnr = opts._update_win
-- Create/get the buffer
local floating_bufnr --- @type integer
if floating_winnr then
floating_bufnr = api.nvim_win_get_buf(floating_winnr)
else
-- check if this popup is focusable and we need to focus
if opts.focus_id and opts.focusable ~= false and opts.focus then
-- Go back to previous window if we are in a focusable one
local current_winnr = api.nvim_get_current_win()
if vim.w[current_winnr][opts.focus_id] then
api.nvim_command('wincmd p')
return bufnr, current_winnr
end
do
local win = find_window_by_var(opts.focus_id, bufnr)
if win and api.nvim_win_is_valid(win) and vim.fn.pumvisible() == 0 then
-- focus and return the existing buf, win
api.nvim_set_current_win(win)
api.nvim_command('stopinsert')
return api.nvim_win_get_buf(win), win
end
end
end
-- check if another floating preview already exists for this buffer
-- and close it if needed
local existing_float = vim.b[bufnr].lsp_floating_preview
if existing_float and api.nvim_win_is_valid(existing_float) then
api.nvim_win_close(existing_float, true)
end
floating_bufnr = api.nvim_create_buf(false, true)
end
-- Set up the contents, using treesitter for markdown
local do_stylize = syntax == 'markdown' and vim.g.syntax_on ~= nil
if do_stylize then
local width = M._make_floating_popup_size(contents, opts)
contents = M._normalize_markdown(contents, { width = width })
else
-- Clean up input: trim empty lines
contents = vim.split(table.concat(contents, '\n'), '\n', { trimempty = true })
if syntax then
vim.bo[floating_bufnr].syntax = syntax
end
end
vim.bo[floating_bufnr].modifiable = true
api.nvim_buf_set_lines(floating_bufnr, 0, -1, false, contents)
if floating_winnr then
api.nvim_win_set_config(floating_winnr, {
border = opts.border,
title = opts.title,
})
else
-- Compute size of float needed to show (wrapped) lines
if opts.wrap then
opts.wrap_at = opts.wrap_at or api.nvim_win_get_width(0)
else
opts.wrap_at = nil
end
-- TODO(lewis6991): These function assume the current window to determine options,
-- therefore it won't work for opts._update_win and the current window if the floating
-- window
local width, height = M._make_floating_popup_size(contents, opts)
local float_option = M.make_floating_popup_options(width, height, opts)
floating_winnr = api.nvim_open_win(floating_bufnr, false, float_option)
api.nvim_buf_set_keymap(
floating_bufnr,
'n',
'q',
'<cmd>bdelete<cr>',
{ silent = true, noremap = true, nowait = true }
)
close_preview_autocmd(opts.close_events, floating_winnr, floating_bufnr, bufnr)
-- save focus_id
if opts.focus_id then
api.nvim_win_set_var(floating_winnr, opts.focus_id, bufnr)
end
api.nvim_buf_set_var(bufnr, 'lsp_floating_preview', floating_winnr)
api.nvim_win_set_var(floating_winnr, 'lsp_floating_bufnr', bufnr)
end
api.nvim_create_autocmd('WinClosed', {
group = api.nvim_create_augroup('nvim.closing_floating_preview', { clear = true }),
callback = function(args)
local winid = vim._tointeger(args.match)
local preview_bufnr = vim.w[winid].lsp_floating_bufnr
if
preview_bufnr
and api.nvim_buf_is_valid(preview_bufnr)
and winid == vim.b[preview_bufnr].lsp_floating_preview
then
vim.b[bufnr].lsp_floating_preview = nil
return true
end
end,
})
vim.wo[floating_winnr].foldenable = false -- Disable folding.
vim.wo[floating_winnr].wrap = opts.wrap -- Soft wrapping.
vim.wo[floating_winnr].linebreak = true -- Break lines a bit nicer
vim.wo[floating_winnr].breakindent = true -- Slightly better list presentation.
vim.wo[floating_winnr].smoothscroll = true -- Scroll by screen-line instead of buffer-line.
vim.wo[floating_winnr].winfixbuf = true -- Disable buffer switching.
vim.bo[floating_bufnr].modifiable = false
vim.bo[floating_bufnr].bufhidden = 'wipe'
if do_stylize then
vim.wo[floating_winnr].conceallevel = 2
vim.wo[floating_winnr].concealcursor = ''
vim.bo[floating_bufnr].filetype = 'markdown'
vim.treesitter.start(floating_bufnr)
if not opts.height then
-- Reduce window height if TS highlighter conceals code block backticks.
local win_height = api.nvim_win_get_height(floating_winnr)
local text_height = api.nvim_win_text_height(floating_winnr, { max_height = win_height }).all
if text_height < win_height then
api.nvim_win_set_height(floating_winnr, text_height)
end
end
end
return floating_bufnr, floating_winnr
end
do --[[ References ]]
local reference_ns = api.nvim_create_namespace('nvim.lsp.references')
--- Removes document highlights from a buffer.
---
---@param bufnr integer? Buffer id
function M.buf_clear_references(bufnr)
api.nvim_buf_clear_namespace(bufnr or 0, reference_ns, 0, -1)
end
--- Shows a list of document highlights for a certain buffer.
---
---@param bufnr integer Buffer id
---@param references lsp.DocumentHighlight[] objects to highlight
---@param position_encoding 'utf-8'|'utf-16'|'utf-32'
---@see https://microsoft.github.io/language-server-protocol/specification/#textDocumentContentChangeEvent
function M.buf_highlight_references(bufnr, references, position_encoding)
validate('bufnr', bufnr, 'number', true)
validate('position_encoding', position_encoding, 'string', false)
for _, reference in ipairs(references) do
local range = vim.range.lsp(bufnr, reference.range, position_encoding)
local document_highlight_kind = {
[protocol.DocumentHighlightKind.Text] = 'LspReferenceText',
[protocol.DocumentHighlightKind.Read] = 'LspReferenceRead',
[protocol.DocumentHighlightKind.Write] = 'LspReferenceWrite',
}
local kind = reference['kind'] or protocol.DocumentHighlightKind.Text
vim.hl.range(
bufnr,
reference_ns,
document_highlight_kind[kind],
{ range.start_row, range.start_col },
{ range.end_row, range.end_col },
{ priority = vim.hl.priorities.user }
)
end
end
end
local position_sort = sort_by_key(function(v)
return { v.start.line, v.start.character }
end)
--- Returns the items with the byte position calculated correctly and in sorted
--- order, for display in quickfix and location lists.
---
--- The `user_data` field of each resulting item will contain the original
--- `Location` or `LocationLink` it was computed from.
---
--- The result can be passed to the {list} argument of |setqflist()| or
--- |setloclist()|.
---
---@param locations lsp.Location[]|lsp.LocationLink[]
---@param position_encoding 'utf-8'|'utf-16'|'utf-32'
---@return vim.quickfix.entry[] # See |setqflist()| for the format
function M.locations_to_items(locations, position_encoding)
vim.validate('position_encoding', position_encoding, 'string')
local items = {} --- @type vim.quickfix.entry[]
---@type table<string, {start: lsp.Position, end: lsp.Position, location: lsp.Location|lsp.LocationLink}[]>
local grouped = {}
for _, d in ipairs(locations) do
-- locations may be Location or LocationLink
local uri = d.uri or d.targetUri
local range = d.range or d.targetSelectionRange
grouped[uri] = grouped[uri] or {}
table.insert(grouped[uri], { start = range.start, ['end'] = range['end'], location = d })
end
for uri, rows in vim.spairs(grouped) do
table.sort(rows, position_sort)
local filename = vim.uri_to_fname(uri)
local line_numbers = {}
for _, temp in ipairs(rows) do
table.insert(line_numbers, temp.start.line)
if temp.start.line ~= temp['end'].line then
table.insert(line_numbers, temp['end'].line)
end
end
-- get all the lines for this uri
local lines = get_lines(vim.uri_to_bufnr(uri), line_numbers)
for _, temp in ipairs(rows) do
local pos = temp.start
local end_pos = temp['end']
local row = pos.line
local end_row = end_pos.line
local line = lines[row] or ''
local end_line = lines[end_row] or ''
local col = vim.str_byteindex(line, position_encoding, pos.character, false)
local end_col = vim.str_byteindex(end_line, position_encoding, end_pos.character, false)
items[#items + 1] = {
filename = filename,
lnum = row + 1,
end_lnum = end_row + 1,
col = col + 1,
end_col = end_col + 1,
text = line,
user_data = temp.location,
}
end
end
return items
end
--- Converts symbols to quickfix list items.
---
---@param symbols lsp.DocumentSymbol[]|lsp.SymbolInformation[]|lsp.WorkspaceSymbol[] list of symbols
---@param bufnr? integer buffer handle or 0 for current, defaults to current
---@param position_encoding 'utf-8'|'utf-16'|'utf-32'
---@return vim.quickfix.entry[] # See |setqflist()| for the format
function M.symbols_to_items(symbols, bufnr, position_encoding)
vim.validate('position_encoding', position_encoding, 'string')
bufnr = vim._resolve_bufnr(bufnr)
local items = {} --- @type vim.quickfix.entry[]
for _, symbol in ipairs(symbols) do
--- @type string?, vim.Range?
local filename, range
if symbol.location then
--- @cast symbol lsp.SymbolInformation
filename = vim.uri_to_fname(symbol.location.uri)
range = vim.range.lsp(bufnr, symbol.location.range, position_encoding)
elseif symbol.selectionRange then
--- @cast symbol lsp.DocumentSymbol
filename = api.nvim_buf_get_name(bufnr)
range = vim.range.lsp(bufnr, symbol.selectionRange, position_encoding)
end
if filename and range then
local kind = protocol.SymbolKind[symbol.kind] or 'Unknown'
local is_deprecated = not vim.isnil(symbol.deprecated or nil)
or (
not vim.isnil(symbol.tags)
and vim.tbl_contains(symbol.tags, protocol.SymbolTag.Deprecated)
)
local text = string.format(
'[%s] %s%s%s',
kind,
symbol.name,
not vim.isnil(symbol.containerName) and ' in ' .. symbol.containerName or '',
is_deprecated and ' (deprecated)' or ''
)
items[#items + 1] = {
filename = filename,
lnum = range.start_row + 1,
col = range.start_col + 1,
end_lnum = range.end_row + 1,
end_col = range.end_col + 1,
kind = kind,
text = text,
}
end
if symbol.children then
list_extend(items, M.symbols_to_items(symbol.children, bufnr, position_encoding))
end
end
return items
end
--- Creates a `TextDocumentPositionParams` object for the current buffer and cursor position.
---
---@param win integer?: |window-ID| or 0 for current, defaults to current
---@param position_encoding 'utf-8'|'utf-16'|'utf-32'
---@return lsp.TextDocumentPositionParams
---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocumentPositionParams
function M.make_position_params(win, position_encoding)
win = win or 0
local buf = api.nvim_win_get_buf(win)
return {
textDocument = M.make_text_document_params(buf),
position = vim.pos.cursor(buf, api.nvim_win_get_cursor(win)):to_lsp(position_encoding),
}
end
--- Using the current position in the current buffer, creates an object that
--- can be used as a building block for several LSP requests, such as
--- `textDocument/codeAction`, `textDocument/colorPresentation`,
--- `textDocument/rangeFormatting`.
---
---@param win integer?: |window-ID| or 0 for current, defaults to current
---@param position_encoding "utf-8"|"utf-16"|"utf-32"
---@return { textDocument: { uri: lsp.DocumentUri }, range: lsp.Range }
function M.make_range_params(win, position_encoding)
win = win or 0
local buf = api.nvim_win_get_buf(win)
local position = vim.pos.cursor(buf, api.nvim_win_get_cursor(win)):to_lsp(position_encoding)
return {
textDocument = M.make_text_document_params(buf),
range = { start = position, ['end'] = position },
}
end
--- Using the given range in the current buffer, creates an object that
--- is similar to |vim.lsp.util.make_range_params()|.
---
---@param start_pos [integer,integer]? {row,col} mark-indexed position.
--- Defaults to the start of the last visual selection.
---@param end_pos [integer,integer]? {row,col} mark-indexed position.
--- Defaults to the end of the last visual selection.
---@param bufnr integer? buffer handle or 0 for current, defaults to current
---@param position_encoding 'utf-8'|'utf-16'|'utf-32'
---@return { textDocument: { uri: lsp.DocumentUri }, range: lsp.Range }
function M.make_given_range_params(start_pos, end_pos, bufnr, position_encoding)
validate('start_pos', start_pos, 'table', true)
validate('end_pos', end_pos, 'table', true)
validate('position_encoding', position_encoding, 'string')
bufnr = vim._resolve_bufnr(bufnr)
local start_row, start_col = unpack(start_pos or api.nvim_buf_get_mark(bufnr, '<'))
local end_row, end_col = unpack(end_pos or api.nvim_buf_get_mark(bufnr, '>'))
return {
textDocument = M.make_text_document_params(bufnr),
range = vim.range.mark(bufnr, start_row, start_col, end_row, end_col):to_lsp(position_encoding),
}
end
--- Creates a `TextDocumentIdentifier` object for the current buffer.
---
---@param bufnr integer?: Buffer handle, defaults to current
---@return lsp.TextDocumentIdentifier
---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocumentIdentifier
function M.make_text_document_params(bufnr)
return { uri = vim.uri_from_bufnr(bufnr or 0) }
end
--- Create the workspace params
---@param added lsp.WorkspaceFolder[]
---@param removed lsp.WorkspaceFolder[]
---@return lsp.DidChangeWorkspaceFoldersParams
function M.make_workspace_params(added, removed)
return { event = { added = added, removed = removed } }
end
--- Returns indentation size.
---
---@see 'shiftwidth'
---@param bufnr integer?: Buffer handle, defaults to current
---@return integer indentation size
function M.get_effective_tabstop(bufnr)
validate('bufnr', bufnr, 'number', true)
local bo = bufnr and vim.bo[bufnr] or vim.bo
local sw = bo.shiftwidth
return (sw == 0 and bo.tabstop) or sw
end
--- Creates a `DocumentFormattingParams` object for the current buffer and cursor position.
---
---@param options lsp.FormattingOptions? with valid `FormattingOptions` entries
---@return lsp.DocumentFormattingParams object
---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_formatting
function M.make_formatting_params(options)
validate('options', options, 'table', true)
options = vim.tbl_extend('keep', options or {}, {
tabSize = M.get_effective_tabstop(),
insertSpaces = vim.bo.expandtab,
})
return {
textDocument = { uri = vim.uri_from_bufnr(0) },
options = options,
}
end
--- Returns the UTF-32 and UTF-16 offsets for a position in a certain buffer.
---
---@deprecated
---@param buf integer buffer number (0 for current)
---@param row integer 0-indexed line
---@param col integer 0-indexed byte offset in line
---@param position_encoding 'utf-8'|'utf-16'|'utf-32'
---@return integer `position_encoding` index of the character in line {row} column {col} in buffer {buf}
function M.character_offset(buf, row, col, position_encoding)
vim.deprecate('vim.lsp.util.character_offset', 'vim.str_utfindex', '0.14')
vim.validate('position_encoding', position_encoding, 'string')
local line = get_line(buf, row)
return vim.str_utfindex(line, position_encoding, col, false)
end
---@class (private) vim.lsp.util._cancel_requests.Filter
---@field bufnr? integer
---@field clients? vim.lsp.Client[]
---@field method? vim.lsp.protocol.Method.ClientToServer.Request
---@field type? string
--- Cancel all {filter}ed requests.
---
---@param filter? vim.lsp.util._cancel_requests.Filter
function M._cancel_requests(filter)
filter = filter or {}
local bufnr = filter.bufnr and vim._resolve_bufnr(filter.bufnr) or nil
local clients = filter.clients
local method = filter.method
local type = filter.type
for _, client in
ipairs(clients or vim.lsp.get_clients({
bufnr = bufnr,
method = method,
}))
do
for id, request in pairs(client.requests) do
if
(bufnr == nil or bufnr == request.bufnr)
and (method == nil or method == request.method)
and (type == nil or type == request.type)
then
client:cancel_request(id)
end
end
end
end
---@nodoc
---@type table<integer,integer>
M.buf_versions = setmetatable({}, {
__index = function(t, bufnr)
return rawget(t, bufnr) or 0
end,
})
return M