1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933 |
- local protocol = require 'vim.lsp.protocol'
- local snippet = require 'vim.lsp._snippet'
- local vim = vim
- local validate = vim.validate
- local api = vim.api
- local list_extend = vim.list_extend
- local highlight = require 'vim.highlight'
- local uv = vim.loop
- local npcall = vim.F.npcall
- local split = vim.split
- local M = {}
- local default_border = {
- {"", "NormalFloat"},
- {"", "NormalFloat"},
- {"", "NormalFloat"},
- {" ", "NormalFloat"},
- {"", "NormalFloat"},
- {"", "NormalFloat"},
- {"", "NormalFloat"},
- {" ", "NormalFloat"},
- }
- ---@private
- --- Check the border given by opts or the default border for the additional
- --- size it adds to a float.
- ---@param opts (table, optional) options for the floating window
- --- - border (string or table) the border
- ---@returns (table) size of border in the form of { height = height, width = width }
- local function get_border_size(opts)
- local border = opts and opts.border or default_border
- local height = 0
- local width = 0
- if type(border) == 'string' then
- local border_size = {none = {0, 0}, single = {2, 2}, double = {2, 2}, rounded = {2, 2}, solid = {2, 2}, shadow = {1, 1}}
- if border_size[border] == nil then
- error(string.format("invalid floating preview border: %s. :help vim.api.nvim_open_win()", vim.inspect(border)))
- end
- height, width = unpack(border_size[border])
- else
- if 8 % #border ~= 0 then
- error(string.format("invalid floating preview border: %s. :help vim.api.nvim_open_win()", vim.inspect(border)))
- end
- ---@private
- local function border_width(id)
- id = (id - 1) % #border + 1
- if type(border[id]) == "table" then
- -- border specified as a table of <character, highlight group>
- return vim.fn.strdisplaywidth(border[id][1])
- elseif type(border[id]) == "string" then
- -- border specified as a list of border characters
- return vim.fn.strdisplaywidth(border[id])
- end
- error(string.format("invalid floating preview border: %s. :help vim.api.nvim_open_win()", vim.inspect(border)))
- end
- ---@private
- local function border_height(id)
- id = (id - 1) % #border + 1
- if type(border[id]) == "table" then
- -- border specified as a table of <character, highlight group>
- return #border[id][1] > 0 and 1 or 0
- elseif type(border[id]) == "string" then
- -- border specified as a list of border characters
- return #border[id] > 0 and 1 or 0
- end
- error(string.format("invalid floating preview border: %s. :help vim.api.nvim_open_win()", vim.inspect(border)))
- end
- height = height + border_height(2) -- top
- height = height + border_height(6) -- bottom
- width = width + border_width(4) -- right
- width = width + border_width(8) -- left
- end
- return { height = height, width = width }
- end
- ---@private
- local function split_lines(value)
- return split(value, '\n', true)
- end
- --- Convert byte index to `encoding` index.
- --- Convenience wrapper around vim.str_utfindex
- ---@param line string line to be indexed
- ---@param index number byte index (utf-8), or `nil` for length
- ---@param encoding string utf-8|utf-16|utf-32|nil defaults to utf-16
- ---@return number `encoding` index of `index` in `line`
- function M._str_utfindex_enc(line, index, encoding)
- if not encoding then encoding = 'utf-16' end
- if encoding == 'utf-8' then
- if index then return index else return #line end
- elseif encoding == 'utf-16' then
- local _, col16 = vim.str_utfindex(line, index)
- return col16
- elseif encoding == 'utf-32' then
- local col32, _ = vim.str_utfindex(line, index)
- return col32
- else
- error("Invalid encoding: " .. vim.inspect(encoding))
- end
- end
- --- Convert UTF index to `encoding` index.
- --- Convenience wrapper around vim.str_byteindex
- ---Alternative to vim.str_byteindex that takes an encoding.
- ---@param line string line to be indexed
- ---@param index number UTF index
- ---@param encoding string utf-8|utf-16|utf-32|nil defaults to utf-16
- ---@return number byte (utf-8) index of `encoding` index `index` in `line`
- function M._str_byteindex_enc(line, index, encoding)
- if not encoding then encoding = 'utf-16' end
- if encoding == 'utf-8' then
- if index then return index else return #line end
- elseif encoding == 'utf-16' then
- return vim.str_byteindex(line, index, true)
- elseif encoding == 'utf-32' then
- return vim.str_byteindex(line, index)
- else
- error("Invalid encoding: " .. vim.inspect(encoding))
- end
- end
- local _str_utfindex_enc = M._str_utfindex_enc
- local _str_byteindex_enc = M._str_byteindex_enc
- --- Replaces text in a range with new text.
- ---
- --- CAUTION: Changes in-place!
- ---
- ---@param lines (table) Original list of strings
- ---@param A (table) Start position; a 2-tuple of {line, col} numbers
- ---@param B (table) End position; a 2-tuple of {line, col} numbers
- ---@param new_lines A list of strings to replace the original
- ---@returns (table) The modified {lines} object
- function M.set_lines(lines, A, B, new_lines)
- -- 0-indexing to 1-indexing
- local i_0 = A[1] + 1
- -- If it extends past the end, truncate it to the end. This is because the
- -- way the LSP describes the range including the last newline is by
- -- specifying a line number after what we would call the last line.
- local i_n = math.min(B[1] + 1, #lines)
- if not (i_0 >= 1 and i_0 <= #lines + 1 and i_n >= 1 and i_n <= #lines) then
- error("Invalid range: "..vim.inspect{A = A; B = B; #lines, new_lines})
- end
- local prefix = ""
- local suffix = lines[i_n]:sub(B[2]+1)
- if A[2] > 0 then
- prefix = lines[i_0]:sub(1, A[2])
- end
- local n = i_n - i_0 + 1
- if n ~= #new_lines then
- for _ = 1, n - #new_lines do table.remove(lines, i_0) end
- for _ = 1, #new_lines - n do table.insert(lines, i_0, '') end
- end
- for i = 1, #new_lines do
- lines[i - 1 + i_0] = new_lines[i]
- end
- if #suffix > 0 then
- local i = i_0 + #new_lines - 1
- lines[i] = lines[i]..suffix
- end
- if #prefix > 0 then
- lines[i_0] = prefix..lines[i_0]
- end
- return lines
- end
- ---@private
- 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
- ---@private
- --- Gets the zero-indexed lines from the given buffer.
- --- Works on unloaded buffers by reading the file using libuv to bypass buf reading events.
- --- Falls back to loading the buffer and nvim_buf_get_lines for buffers with non-file URI.
- ---
- ---@param bufnr number bufnr to get the lines from
- ---@param rows number[] zero-indexed line numbers
- ---@return table<number string> a table mapping rows to lines
- local function get_lines(bufnr, rows)
- rows = type(rows) == "table" and rows or { rows }
- -- This is needed for bufload and bufloaded
- if bufnr == 0 then
- bufnr = vim.api.nvim_get_current_buf()
- end
- ---@private
- local function buf_lines()
- local lines = {}
- for _, row in pairs(rows) do
- lines[row] = (vim.api.nvim_buf_get_lines(bufnr, row, row + 1, false) or { "" })[1]
- end
- return lines
- end
- local uri = vim.uri_from_bufnr(bufnr)
- -- load the buffer if this is not a file uri
- -- Custom language server protocol extensions can result in servers sending URIs with custom schemes. Plugins are able to load these via `BufReadCmd` autocmds.
- if uri:sub(1, 4) ~= "file" then
- vim.fn.bufload(bufnr)
- return buf_lines()
- end
- -- use loaded buffers if available
- if vim.fn.bufloaded(bufnr) == 1 then
- return buf_lines()
- end
- local filename = api.nvim_buf_get_name(bufnr)
- -- get the data from the file
- local fd = uv.fs_open(filename, "r", 438)
- if not fd then return "" end
- local stat = uv.fs_fstat(fd)
- local data = uv.fs_read(fd, stat.size, 0)
- uv.fs_close(fd)
- local lines = {} -- rows we need to retrieve
- local need = 0 -- keep track of how many unique rows we need
- for _, row in pairs(rows) do
- if not lines[row] then
- need = need + 1
- end
- lines[row] = true
- end
- local found = 0
- local lnum = 0
- for line in string.gmatch(data, "([^\n]*)\n?") do
- if lines[lnum] == true then
- lines[lnum] = line
- found = found + 1
- if found == need then break end
- end
- lnum = lnum + 1
- end
- -- change any lines we didn't find to the empty string
- for i, line in pairs(lines) do
- if line == true then
- lines[i] = ""
- end
- end
- return lines
- end
- ---@private
- --- Gets the zero-indexed line from the given buffer.
- --- Works on unloaded buffers by reading the file using libuv to bypass buf reading events.
- --- Falls back to loading the buffer and nvim_buf_get_lines for buffers with non-file URI.
- ---
- ---@param bufnr number
- ---@param row number zero-indexed line number
- ---@return string the line at row in filename
- local function get_line(bufnr, row)
- return get_lines(bufnr, { row })[row]
- end
- ---@private
- --- Position is a https://microsoft.github.io/language-server-protocol/specifications/specification-current/#position
- --- Returns a zero-indexed column, since set_lines() does the conversion to
- ---@param offset_encoding string utf-8|utf-16|utf-32|nil defaults to utf-16
- --- 1-indexed
- local function get_line_byte_from_position(bufnr, position, offset_encoding)
- -- LSP's line and characters are 0-indexed
- -- Vim's line and columns are 1-indexed
- local col = position.character
- -- When on the first character, we can ignore the difference between byte and
- -- character
- if col > 0 then
- local line = get_line(bufnr, position.line)
- local ok, result
- ok, result = pcall(_str_byteindex_enc, line, col, offset_encoding)
- if ok then
- return result
- end
- return math.min(#line, col)
- end
- return col
- end
- --- Process and return progress reports from lsp server
- ---@private
- function M.get_progress_messages()
- local new_messages = {}
- local msg_remove = {}
- local progress_remove = {}
- for _, client in ipairs(vim.lsp.get_active_clients()) do
- local messages = client.messages
- local data = messages
- for token, ctx in pairs(data.progress) do
- local new_report = {
- name = data.name,
- title = ctx.title or "empty title",
- message = ctx.message,
- percentage = ctx.percentage,
- done = ctx.done,
- progress = true,
- }
- table.insert(new_messages, new_report)
- if ctx.done then
- table.insert(progress_remove, {client = client, token = token})
- end
- end
- for i, msg in ipairs(data.messages) do
- if msg.show_once then
- msg.shown = msg.shown + 1
- if msg.shown > 1 then
- table.insert(msg_remove, {client = client, idx = i})
- end
- end
- table.insert(new_messages, {name = data.name, content = msg.content})
- end
- if next(data.status) ~= nil then
- table.insert(new_messages, {
- name = data.name,
- content = data.status.content,
- uri = data.status.uri,
- status = true
- })
- end
- for _, item in ipairs(msg_remove) do
- table.remove(client.messages, item.idx)
- end
- end
- for _, item in ipairs(progress_remove) do
- item.client.messages.progress[item.token] = nil
- end
- return new_messages
- end
- --- Applies a list of text edits to a buffer.
- ---@param text_edits table list of `TextEdit` objects
- ---@param bufnr number Buffer id
- ---@param offset_encoding string utf-8|utf-16|utf-32|nil defaults to encoding of first client of `bufnr`
- ---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textEdit
- function M.apply_text_edits(text_edits, bufnr, offset_encoding)
- validate {
- text_edits = { text_edits, 't', false };
- bufnr = { bufnr, 'number', false };
- offset_encoding = { offset_encoding, 'string', true };
- }
- offset_encoding = offset_encoding or M._get_offset_encoding(bufnr)
- if not next(text_edits) then return end
- if not api.nvim_buf_is_loaded(bufnr) then
- vim.fn.bufload(bufnr)
- end
- api.nvim_buf_set_option(bufnr, 'buflisted', true)
- -- Fix reversed range and indexing each text_edits
- local index = 0
- text_edits = vim.tbl_map(function(text_edit)
- index = index + 1
- text_edit._index = index
- 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
- return text_edit
- end, text_edits)
- -- Sort text_edits
- 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
- if a._index ~= b._index then
- return a._index > b._index
- end
- end)
- -- 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.
- local has_eol_text_edit = false
- local max = vim.api.nvim_buf_line_count(bufnr)
- local len = _str_utfindex_enc(vim.api.nvim_buf_get_lines(bufnr, -2, -1, false)[1] or '', nil, offset_encoding)
- text_edits = vim.tbl_map(function(text_edit)
- if max <= text_edit.range.start.line then
- text_edit.range.start.line = max - 1
- text_edit.range.start.character = len
- text_edit.newText = '\n' .. text_edit.newText
- has_eol_text_edit = true
- end
- if max <= text_edit.range['end'].line then
- text_edit.range['end'].line = max - 1
- text_edit.range['end'].character = len
- has_eol_text_edit = true
- end
- return text_edit
- end, text_edits)
- -- Some LSP servers are depending on the VSCode behavior.
- -- The VSCode will re-locate the cursor position after applying TextEdit so we also do it.
- local is_current_buf = vim.api.nvim_get_current_buf() == bufnr
- local cursor = (function()
- if not is_current_buf then
- return {
- row = -1,
- col = -1,
- }
- end
- local cursor = vim.api.nvim_win_get_cursor(0)
- return {
- row = cursor[1] - 1,
- col = cursor[2],
- }
- end)()
- -- Apply text edits.
- local is_cursor_fixed = false
- for _, text_edit in ipairs(text_edits) do
- local e = {
- start_row = text_edit.range.start.line,
- start_col = get_line_byte_from_position(bufnr, text_edit.range.start),
- end_row = text_edit.range['end'].line,
- end_col = get_line_byte_from_position(bufnr, text_edit.range['end']),
- text = vim.split(text_edit.newText, '\n', true),
- }
- vim.api.nvim_buf_set_text(bufnr, e.start_row, e.start_col, e.end_row, e.end_col, e.text)
- local row_count = (e.end_row - e.start_row) + 1
- if e.end_row < cursor.row then
- cursor.row = cursor.row + (#e.text - row_count)
- is_cursor_fixed = true
- elseif e.end_row == cursor.row and e.end_col <= cursor.col then
- cursor.row = cursor.row + (#e.text - row_count)
- cursor.col = #e.text[#e.text] + (cursor.col - e.end_col)
- if #e.text == 1 then
- cursor.col = cursor.col + e.start_col
- end
- is_cursor_fixed = true
- end
- end
- if is_cursor_fixed then
- local is_valid_cursor = true
- is_valid_cursor = is_valid_cursor and cursor.row < vim.api.nvim_buf_line_count(bufnr)
- is_valid_cursor = is_valid_cursor and cursor.col <= #(vim.api.nvim_buf_get_lines(bufnr, cursor.row, cursor.row + 1, false)[1] or '')
- if is_valid_cursor then
- vim.api.nvim_win_set_cursor(0, { cursor.row + 1, cursor.col })
- end
- end
- -- Remove final line if needed
- local fix_eol = has_eol_text_edit
- fix_eol = fix_eol and api.nvim_buf_get_option(bufnr, 'fixeol')
- fix_eol = fix_eol and (vim.api.nvim_buf_get_lines(bufnr, -2, -1, false)[1] or '') == ''
- if fix_eol then
- vim.api.nvim_buf_set_lines(bufnr, -2, -1, false, {})
- end
- end
- -- local valid_windows_path_characters = "[^<>:\"/\\|?*]"
- -- local valid_unix_path_characters = "[^/]"
- -- https://github.com/davidm/lua-glob-pattern
- -- https://stackoverflow.com/questions/1976007/what-characters-are-forbidden-in-windows-and-linux-directory-names
- -- function M.glob_to_regex(glob)
- -- end
- --- Can be used to extract the completion items from a
- --- `textDocument/completion` request, which may return one of
- --- `CompletionItem[]`, `CompletionList` or null.
- ---@param result (table) The result of a `textDocument/completion` request
- ---@returns (table) List of completion items
- ---@see https://microsoft.github.io/language-server-protocol/specification#textDocument_completion
- function M.extract_completion_items(result)
- if type(result) == 'table' and result.items then
- -- result is a `CompletionList`
- return result.items
- elseif result ~= nil then
- -- result is `CompletionItem[]`
- return result
- else
- -- result is `null`
- return {}
- end
- end
- --- Applies a `TextDocumentEdit`, which is a list of changes to a single
- --- document.
- ---
- ---@param text_document_edit table: a `TextDocumentEdit` object
- ---@param index number: Optional index of the edit, if from a list of edits (or nil, if not from a list)
- ---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocumentEdit
- function M.apply_text_document_edit(text_document_edit, index)
- local text_document = text_document_edit.textDocument
- local bufnr = vim.uri_to_bufnr(text_document.uri)
- -- For lists of text document edits,
- -- do not check the version after the first edit.
- local should_check_version = true
- if index and index > 1 then
- should_check_version = false
- end
- -- `VersionedTextDocumentIdentifier`s version may be null
- -- https://microsoft.github.io/language-server-protocol/specification#versionedTextDocumentIdentifier
- if should_check_version and (text_document.version
- and text_document.version > 0
- and M.buf_versions[bufnr]
- 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)
- end
- --- Parses snippets in a completion entry.
- ---
- ---@param input string unparsed snippet
- ---@returns string parsed snippet
- function M.parse_snippet(input)
- local ok, parsed = pcall(function()
- return tostring(snippet.parse(input))
- end)
- if not ok then
- return input
- end
- return parsed
- end
- ---@private
- --- Sorts by CompletionItem.sortText.
- ---
- --see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_completion
- local function sort_completion_items(items)
- table.sort(items, function(a, b)
- return (a.sortText or a.label) < (b.sortText or b.label)
- end)
- end
- ---@private
- --- Returns text that should be inserted when selecting completion item. The
- --- precedence is as follows: textEdit.newText > insertText > label
- --see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_completion
- local function get_completion_word(item)
- if item.textEdit ~= nil and item.textEdit.newText ~= nil and item.textEdit.newText ~= "" then
- local insert_text_format = protocol.InsertTextFormat[item.insertTextFormat]
- if insert_text_format == "PlainText" or insert_text_format == nil then
- return item.textEdit.newText
- else
- return M.parse_snippet(item.textEdit.newText)
- end
- elseif item.insertText ~= nil and item.insertText ~= "" then
- local insert_text_format = protocol.InsertTextFormat[item.insertTextFormat]
- if insert_text_format == "PlainText" or insert_text_format == nil then
- return item.insertText
- else
- return M.parse_snippet(item.insertText)
- end
- end
- return item.label
- end
- ---@private
- --- Some language servers return complementary candidates whose prefixes do not
- --- match are also returned. So we exclude completion candidates whose prefix
- --- does not match.
- local function remove_unmatch_completion_items(items, prefix)
- return vim.tbl_filter(function(item)
- local word = get_completion_word(item)
- return vim.startswith(word, prefix)
- end, items)
- end
- --- According to LSP spec, if the client set `completionItemKind.valueSet`,
- --- the client must handle it properly even if it receives a value outside the
- --- specification.
- ---
- ---@param completion_item_kind (`vim.lsp.protocol.completionItemKind`)
- ---@returns (`vim.lsp.protocol.completionItemKind`)
- ---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_completion
- function M._get_completion_item_kind_name(completion_item_kind)
- return protocol.CompletionItemKind[completion_item_kind] or "Unknown"
- end
- --- Turns the result of a `textDocument/completion` request into vim-compatible
- --- |complete-items|.
- ---
- ---@param result The result of a `textDocument/completion` call, e.g. from
- ---|vim.lsp.buf.completion()|, which may be one of `CompletionItem[]`,
- --- `CompletionList` or `null`
- ---@param prefix (string) the prefix to filter the completion items
- ---@returns { matches = complete-items table, incomplete = bool }
- ---@see |complete-items|
- function M.text_document_completion_list_to_complete_items(result, prefix)
- local items = M.extract_completion_items(result)
- if vim.tbl_isempty(items) then
- return {}
- end
- items = remove_unmatch_completion_items(items, prefix)
- sort_completion_items(items)
- local matches = {}
- for _, completion_item in ipairs(items) do
- local info = ' '
- local documentation = completion_item.documentation
- if documentation then
- if type(documentation) == 'string' and documentation ~= '' then
- info = documentation
- elseif type(documentation) == 'table' and type(documentation.value) == 'string' then
- info = documentation.value
- -- else
- -- TODO(ashkan) Validation handling here?
- end
- end
- local word = get_completion_word(completion_item)
- table.insert(matches, {
- word = word,
- abbr = completion_item.label,
- kind = M._get_completion_item_kind_name(completion_item.kind),
- menu = completion_item.detail or '',
- info = info,
- icase = 1,
- dup = 1,
- empty = 1,
- user_data = {
- nvim = {
- lsp = {
- completion_item = completion_item
- }
- }
- },
- })
- end
- return matches
- end
- --- Rename old_fname to new_fname
- ---
- ---@param opts (table)
- -- overwrite? bool
- -- ignoreIfExists? bool
- function M.rename(old_fname, new_fname, opts)
- opts = opts or {}
- local target_exists = vim.loop.fs_stat(new_fname) ~= nil
- if target_exists and not opts.overwrite or opts.ignoreIfExists then
- vim.notify('Rename target already exists. Skipping rename.')
- return
- end
- local oldbuf = vim.fn.bufadd(old_fname)
- vim.fn.bufload(oldbuf)
- -- The there may be pending changes in the buffer
- api.nvim_buf_call(oldbuf, function()
- vim.cmd('w!')
- end)
- local ok, err = os.rename(old_fname, new_fname)
- assert(ok, err)
- local newbuf = vim.fn.bufadd(new_fname)
- for _, win in pairs(api.nvim_list_wins()) do
- if api.nvim_win_get_buf(win) == oldbuf then
- api.nvim_win_set_buf(win, newbuf)
- end
- end
- api.nvim_buf_delete(oldbuf, { force = true })
- end
- ---@private
- 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
- local file = io.open(fname, 'w')
- file:close()
- end
- vim.fn.bufadd(fname)
- end
- ---@private
- local function delete_file(change)
- local opts = change.options or {}
- local fname = vim.uri_to_fname(change.uri)
- local stat = vim.loop.fs_stat(fname)
- if opts.ignoreIfNotExists and not stat then
- return
- end
- assert(stat, "Cannot delete not existing file or folder " .. fname)
- local flags
- if stat and stat.type == 'directory' then
- flags = opts.recursive and 'rf' or 'd'
- else
- flags = ''
- end
- local bufnr = vim.fn.bufadd(fname)
- local result = tonumber(vim.fn.delete(fname, flags))
- assert(result == 0, 'Could not delete file: ' .. fname .. ', stat: ' .. vim.inspect(stat))
- api.nvim_buf_delete(bufnr, { force = true })
- end
- --- Applies a `WorkspaceEdit`.
- ---
- ---@param workspace_edit (table) `WorkspaceEdit`
- --see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#workspace_applyEdit
- function M.apply_workspace_edit(workspace_edit)
- if workspace_edit.documentChanges then
- for idx, change in ipairs(workspace_edit.documentChanges) do
- if change.kind == "rename" then
- M.rename(
- vim.uri_to_fname(change.oldUri),
- vim.uri_to_fname(change.newUri),
- change.options
- )
- elseif change.kind == 'create' then
- create_file(change)
- elseif change.kind == 'delete' then
- delete_file(change)
- elseif change.kind then
- error(string.format("Unsupported change: %q", vim.inspect(change)))
- else
- M.apply_text_document_edit(change, idx)
- 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)
- 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.
- ---
- ---@param input (`MarkedString` | `MarkedString[]` | `MarkupContent`)
- ---@param contents (table, optional, default `{}`) List of strings to extend with converted lines
- ---@returns {contents}, 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))
- else
- assert(type(input) == 'table', "Expected a table for Hover.contents")
- -- MarkupContent
- if input.kind then
- -- The kind can be either plaintext or markdown.
- -- If it's plaintext, then wrap it in a <text></text> block
- -- Some servers send input.value as empty, so let's ignore this :(
- local value = input.value or ''
- if input.kind == "plaintext" then
- -- wrap this in a <text></text> block so that stylize_markdown
- -- can properly process it as plaintext
- value = string.format("<text>\n%s\n</text>", value)
- end
- -- assert(type(value) == 'string')
- list_extend(contents, split_lines(value))
- -- MarkupString variation 2
- elseif input.language then
- -- Some servers send input.value as empty, so let's ignore this :(
- -- assert(type(input.value) == 'string')
- 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
- --- Converts `textDocument/SignatureHelp` response to markdown lines.
- ---
- ---@param signature_help Response of `textDocument/SignatureHelp`
- ---@param ft optional filetype that will be use as the `lang` for the label markdown code block
- ---@param triggers optional list of trigger characters from the lsp server. used to better determine parameter offsets
- ---@returns list of lines of converted markdown.
- ---@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)
- if not signature_help.signatures then
- return
- end
- --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 = {}
- local active_hl
- local active_signature = signature_help.activeSignature or 0
- -- If the activeSignature is not inside the valid range, then clip it.
- if active_signature >= #signature_help.signatures then
- active_signature = 0
- end
- local signature = signature_help.signatures[active_signature + 1]
- if not signature then
- return
- end
- local label = signature.label
- if ft then
- -- wrap inside a code block so stylize_markdown can render it properly
- label = ("```%s\n%s\n```"):format(ft, label)
- end
- vim.list_extend(contents, vim.split(label, '\n', true))
- if signature.documentation then
- 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 or 0)
- if active_parameter < 0
- then active_parameter = 0
- end
- -- If the activeParameter is > #parameters, then set it to the last
- -- NOTE: this is not fully according to the spec, but a client-side interpretation
- if active_parameter >= #signature.parameters then
- active_parameter = #signature.parameters - 1
- end
- local parameter = signature.parameters[active_parameter + 1]
- if parameter then
- --[=[
- --Represents a parameter of a callable-signature. A parameter can
- --have a label and a doc-comment.
- interface ParameterInformation {
- --The label of this parameter information.
- --
- --Either a string or an inclusive start and exclusive end offsets within its containing
- --signature label. (see SignatureInformation.label). The offsets are based on a UTF-16
- --string representation as `Position` and `Range` does.
- --
- --*Note*: a label of type string should be a substring of its containing signature label.
- --Its intended use case is to highlight the parameter label part in the `SignatureInformation.label`.
- label: string | [number, number];
- --The human-readable doc-comment of this parameter. Will be shown
- --in the UI but can be omitted.
- documentation?: string | MarkupContent;
- }
- --]=]
- if parameter.label then
- if type(parameter.label) == "table" then
- active_hl = parameter.label
- else
- local offset = 1
- -- 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
- offset = signature.label:find(param.label, offset, true)
- if not offset then break end
- if p == active_parameter + 1 then
- active_hl = {offset - 1, offset + #parameter.label - 1}
- break
- end
- offset = offset + #param.label + 1
- end
- end
- end
- if parameter.documentation then
- M.convert_input_to_markdown_lines(parameter.documentation, contents)
- end
- 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 (number) window width (in character cells)
- ---@param height (number) window height (in character cells)
- ---@param opts (table, optional)
- --- - offset_x (number) offset to add to `col`
- --- - offset_y (number) offset to add to `row`
- --- - border (string or table) override `border`
- --- - focusable (string or table) override `focusable`
- --- - zindex (string or table) override `zindex`, defaults to 50
- ---@returns (table) Options
- function M.make_floating_popup_options(width, height, opts)
- validate {
- opts = { opts, 't', true };
- }
- opts = opts or {}
- validate {
- ["opts.offset_x"] = { opts.offset_x, 'n', true };
- ["opts.offset_y"] = { opts.offset_y, 'n', true };
- }
- local anchor = ''
- local row, col
- local lines_above = vim.fn.winline() - 1
- local lines_below = vim.fn.winheight(0) - lines_above
- if lines_above < lines_below then
- anchor = anchor..'N'
- height = math.min(lines_below, height)
- row = 1
- else
- anchor = anchor..'S'
- height = math.min(lines_above, height)
- row = 0
- end
- if vim.fn.wincol() + width + (opts.offset_x or 0) <= api.nvim_get_option('columns') then
- anchor = anchor..'W'
- col = 0
- else
- anchor = anchor..'E'
- col = 1
- end
- return {
- anchor = anchor,
- col = col + (opts.offset_x or 0),
- height = height,
- focusable = opts.focusable,
- relative = 'cursor',
- row = row + (opts.offset_y or 0),
- style = 'minimal',
- width = width,
- border = opts.border or default_border,
- zindex = opts.zindex or 50,
- }
- end
- --- Jumps to a location.
- ---
- ---@param location (`Location`|`LocationLink`)
- ---@returns `true` if the jump succeeded
- function M.jump_to_location(location)
- -- location may be Location or LocationLink
- local uri = location.uri or location.targetUri
- if uri == nil then return end
- local bufnr = vim.uri_to_bufnr(uri)
- -- 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')
- --- Jump to new location (adjusting for UTF-16 encoding of characters)
- api.nvim_set_current_buf(bufnr)
- api.nvim_buf_set_option(bufnr, 'buflisted', true)
- local range = location.range or location.targetSelectionRange
- local row = range.start.line
- local col = get_line_byte_from_position(bufnr, range.start)
- api.nvim_win_set_cursor(0, {row + 1, col})
- -- Open folds under the cursor
- vim.cmd("normal! zv")
- 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 a single `Location` or `LocationLink`
- ---@returns (bufnr,winnr) buffer and window number of floating window or nil
- 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 = api.nvim_buf_get_option(bufnr, 'syntax')
- if syntax == "" then
- -- When no syntax is set, we use filetype as fallback. This might not result
- -- in a valid syntax definition. See also ft detection in stylize_markdown.
- -- An empty syntax is more common now with TreeSitter, since TS disables syntax.
- syntax = api.nvim_buf_get_option(bufnr, 'filetype')
- end
- opts = opts or {}
- opts.focus_id = "location"
- return M.open_floating_preview(contents, syntax, opts)
- end
- ---@private
- local function find_window_by_var(name, value)
- for _, win in ipairs(api.nvim_list_wins()) do
- if npcall(api.nvim_win_get_var, win, name) == value then
- return win
- end
- end
- end
- --- Trims empty lines from input and pad top and bottom with empty lines
- ---
- ---@param contents table of lines to trim and pad
- ---@param opts dictionary with optional fields
- --- - pad_top number of lines to pad contents at top (default 0)
- --- - pad_bottom number of lines to pad contents at bottom (default 0)
- ---@return contents table of trimmed and padded lines
- function M._trim(contents, opts)
- validate {
- contents = { contents, 't' };
- opts = { opts, 't', true };
- }
- opts = opts or {}
- contents = M.trim_empty_lines(contents)
- if opts.pad_top then
- for _ = 1, opts.pad_top do
- table.insert(contents, 1, "")
- end
- end
- if opts.pad_bottom then
- for _ = 1, opts.pad_bottom do
- table.insert(contents, "")
- end
- end
- return contents
- end
- --- Generates a table mapping markdown code block lang to vim syntax,
- --- based on g:markdown_fenced_languages
- ---@return a table of lang -> syntax mappings
- ---@private
- local function get_markdown_fences()
- local fences = {}
- for _, fence in pairs(vim.g.markdown_fenced_languages or {}) do
- local lang, syntax = fence:match("^(.*)=(.*)$")
- if lang then
- fences[lang] = syntax
- end
- end
- return fences
- end
- --- 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 contents table of lines to show in window
- ---@param opts dictionary with optional fields
- --- - height of floating window
- --- - width of floating window
- --- - wrap_at character to wrap at for computing height
- --- - max_width maximal width of floating window
- --- - max_height maximal height of floating window
- --- - pad_top number of lines to pad contents at top
- --- - pad_bottom number of lines to pad contents at bottom
- --- - separator insert separator after code block
- ---@returns width,height size of float
- function M.stylize_markdown(bufnr, contents, opts)
- validate {
- contents = { contents, 't' };
- opts = { opts, 't', 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, "```+([a-zA-Z0-9_]*)", "```+"},
- pre = {"", "<pre>", "</pre>"},
- code = {"", "<code>", "</code>"},
- text = {"plaintex", "<text>", "</text>"},
- }
- local match_begin = function(line)
- for type, pattern in pairs(matchers) do
- 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
- local match_end = function(line, match)
- local pattern = matchers[match.type]
- return line:match(string.format("^%%s*%s%%s*$", pattern[3]))
- end
- -- Clean up
- contents = M._trim(contents, opts)
- -- Insert blank line separator after code block?
- local add_sep = opts.separator == nil and true or opts.separator
- local stripped = {}
- local highlights = {}
- -- keep track of lnums that contain markdown
- local markdown_lines = {}
- do
- 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
- table.insert(stripped, 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 add_sep and i < #contents then
- table.insert(stripped, "---")
- markdown_lines[#stripped] = true
- end
- else
- -- strip any empty lines or separators prior to this separator in actual markdown
- if line:match("^---+$") then
- while markdown_lines[#stripped] and (stripped[#stripped]:match("^%s*$") or stripped[#stripped]:match("^---+$")) do
- markdown_lines[#stripped] = false
- table.remove(stripped, #stripped)
- end
- end
- -- add the line if its not an empty line following a separator
- if not (line:match("^%s*$") and markdown_lines[#stripped] and stripped[#stripped]:match("^---+$")) then
- table.insert(stripped, line)
- markdown_lines[#stripped] = true
- end
- i = i + 1
- end
- end
- end
- -- 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 pairs(markdown_lines) do
- if stripped[l]:match("^---+$") then
- stripped[l] = sep_line
- end
- end
- vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, stripped)
- local idx = 1
- ---@private
- -- keep track of syntaxes we already included.
- -- no need to include the same syntax more than once
- local langs = {}
- 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(vim.api.nvim_buf_del_var, bufnr, "current_syntax")
- -- TODO(ashkan): better validation before this.
- if not pcall(vim.cmd, string.format("syntax include %s syntax/%s.vim", lang, ft)) then
- return
- end
- 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
- api.nvim_buf_call(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
- ---@private
- --- Creates autocommands to close a preview window when events happen.
- ---
- ---@param events table list of events
- ---@param winnr number window id of preview window
- ---@param bufnrs table list of buffers where the preview window will remain visible
- ---@see |autocmd-events|
- local function close_preview_autocmd(events, winnr, bufnrs)
- local augroup = 'preview_window_'..winnr
- -- close the preview window when entered a buffer that is not
- -- the floating window buffer or the buffer that spawned it
- vim.cmd(string.format([[
- augroup %s
- autocmd!
- autocmd BufEnter * lua vim.lsp.util._close_preview_window(%d, {%s})
- augroup end
- ]], augroup, winnr, table.concat(bufnrs, ',')))
- if #events > 0 then
- vim.cmd(string.format([[
- augroup %s
- autocmd %s <buffer> lua vim.lsp.util._close_preview_window(%d)
- augroup end
- ]], augroup, table.concat(events, ','), winnr))
- end
- end
- ---@private
- --- Closes the preview window
- ---
- ---@param winnr number window id of preview window
- ---@param bufnrs table|nil optional list of ignored buffers
- function M._close_preview_window(winnr, bufnrs)
- vim.schedule(function()
- -- exit if we are in one of ignored buffers
- if bufnrs and vim.tbl_contains(bufnrs, api.nvim_get_current_buf()) then
- return
- end
- local augroup = 'preview_window_'..winnr
- vim.cmd(string.format([[
- augroup %s
- autocmd!
- augroup end
- augroup! %s
- ]], augroup, augroup))
- pcall(vim.api.nvim_win_close, winnr, true)
- end)
- end
- ---@internal
- --- Computes size of float needed to show contents (with optional wrapping)
- ---
- ---@param contents table of lines to show in window
- ---@param opts dictionary with optional fields
- --- - height of floating window
- --- - width of floating window
- --- - wrap_at character to wrap at for computing height
- --- - max_width maximal width of floating window
- --- - max_height maximal height of floating window
- ---@returns width,height size of float
- function M._make_floating_popup_size(contents, opts)
- validate {
- contents = { contents, 't' };
- opts = { opts, 't', 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 = {}
- 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)
- width = math.max(line_widths[i], width)
- end
- end
- local border_width = get_border_size(opts).width
- local screen_width = api.nvim_win_get_width(0)
- width = math.min(width, screen_width)
- -- make sure borders are always inside the screen
- if width + border_width > screen_width then
- width = width - (width + border_width - screen_width)
- end
- if wrap_at and wrap_at > width then
- 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)
- height = height + 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
- --- 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 table with optional fields (additional keys are passed on to |vim.api.nvim_open_win()|)
- --- - height: (number) height of floating window
- --- - width: (number) width of floating window
- --- - wrap: (boolean, default true) wrap long lines
- --- - wrap_at: (string) character to wrap at for computing height when wrap is enabled
- --- - max_width: (number) maximal width of floating window
- --- - max_height: (number) maximal height of floating window
- --- - pad_top: (number) number of lines to pad contents at top
- --- - pad_bottom: (number) number of lines to pad contents at bottom
- --- - focus_id: (string) if a popup with this id is opened, then focus it
- --- - close_events: (table) list of events that closes the floating window
- --- - focusable: (boolean, default true) Make float focusable
- --- - focus: (boolean, default true) If `true`, and if {focusable}
- --- is also `true`, focus an existing floating window with the same
- --- {focus_id}
- ---@returns bufnr,winnr buffer and window number of the newly created floating
- ---preview window
- function M.open_floating_preview(contents, syntax, opts)
- validate {
- contents = { contents, 't' };
- syntax = { syntax, 's', true };
- opts = { opts, 't', true };
- }
- opts = opts or {}
- opts.wrap = opts.wrap ~= false -- wrapping by default
- opts.stylize_markdown = opts.stylize_markdown ~= false
- opts.focus = opts.focus ~= false
- opts.close_events = opts.close_events or {"CursorMoved", "CursorMovedI", "InsertCharPre"}
- local bufnr = api.nvim_get_current_buf()
- -- 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 npcall(api.nvim_win_get_var, 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 = npcall(api.nvim_buf_get_var, bufnr, "lsp_floating_preview")
- if existing_float and api.nvim_win_is_valid(existing_float) then
- api.nvim_win_close(existing_float, true)
- end
- local floating_bufnr = api.nvim_create_buf(false, true)
- local do_stylize = syntax == "markdown" and opts.stylize_markdown
- -- Clean up input: trim empty lines from the end, pad
- contents = M._trim(contents, opts)
- if do_stylize then
- -- applies the syntax and sets the lines to the buffer
- contents = M.stylize_markdown(floating_bufnr, contents, opts)
- else
- if syntax then
- api.nvim_buf_set_option(floating_bufnr, 'syntax', syntax)
- end
- api.nvim_buf_set_lines(floating_bufnr, 0, -1, true, contents)
- end
- -- 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
- local width, height = M._make_floating_popup_size(contents, opts)
- local float_option = M.make_floating_popup_options(width, height, opts)
- local floating_winnr = api.nvim_open_win(floating_bufnr, false, float_option)
- if do_stylize then
- api.nvim_win_set_option(floating_winnr, 'conceallevel', 2)
- api.nvim_win_set_option(floating_winnr, 'concealcursor', 'n')
- end
- -- disable folding
- api.nvim_win_set_option(floating_winnr, 'foldenable', false)
- -- soft wrapping
- api.nvim_win_set_option(floating_winnr, 'wrap', opts.wrap)
- api.nvim_buf_set_option(floating_bufnr, 'modifiable', false)
- api.nvim_buf_set_option(floating_bufnr, 'bufhidden', 'wipe')
- 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)
- return floating_bufnr, floating_winnr
- end
- do --[[ References ]]
- local reference_ns = api.nvim_create_namespace("vim_lsp_references")
- --- Removes document highlights from a buffer.
- ---
- ---@param bufnr number Buffer id
- function M.buf_clear_references(bufnr)
- validate { bufnr = {bufnr, 'n', true} }
- api.nvim_buf_clear_namespace(bufnr, reference_ns, 0, -1)
- end
- --- Shows a list of document highlights for a certain buffer.
- ---
- ---@param bufnr number Buffer id
- ---@param references table List of `DocumentHighlight` objects to highlight
- ---@param offset_encoding string One of "utf-8", "utf-16", "utf-32", or nil. Defaults to `offset_encoding` of first client of `bufnr`
- ---@see https://microsoft.github.io/language-server-protocol/specifications/specification-3-17/#documentHighlight
- function M.buf_highlight_references(bufnr, references, offset_encoding)
- validate { bufnr = {bufnr, 'n', true} }
- offset_encoding = offset_encoding or M._get_offset_encoding(bufnr)
- for _, reference in ipairs(references) do
- local start_line, start_char = reference["range"]["start"]["line"], reference["range"]["start"]["character"]
- local end_line, end_char = reference["range"]["end"]["line"], reference["range"]["end"]["character"]
- local start_idx = get_line_byte_from_position(bufnr, { line = start_line, character = start_char }, offset_encoding)
- local end_idx = get_line_byte_from_position(bufnr, { line = start_line, character = end_char }, offset_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
- highlight.range(bufnr,
- reference_ns,
- document_highlight_kind[kind],
- { start_line, start_idx },
- { end_line, end_idx })
- 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 result can be passed to the {list} argument of |setqflist()| or
- --- |setloclist()|.
- ---
- ---@param locations (table) list of `Location`s or `LocationLink`s
- ---@returns (table) list of items
- function M.locations_to_items(locations)
- local items = {}
- local grouped = setmetatable({}, {
- __index = function(t, k)
- local v = {}
- rawset(t, k, v)
- return v
- end;
- })
- 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
- table.insert(grouped[uri], {start = range.start})
- end
- local keys = vim.tbl_keys(grouped)
- table.sort(keys)
- -- TODO(ashkan) I wish we could do this lazily.
- for _, uri in ipairs(keys) do
- local rows = grouped[uri]
- table.sort(rows, position_sort)
- local filename = vim.uri_to_fname(uri)
- -- list of row numbers
- local uri_rows = {}
- for _, temp in ipairs(rows) do
- local pos = temp.start
- local row = pos.line
- table.insert(uri_rows, row)
- end
- -- get all the lines for this uri
- local lines = get_lines(vim.uri_to_bufnr(uri), uri_rows)
- for _, temp in ipairs(rows) do
- local pos = temp.start
- local row = pos.line
- local line = lines[row] or ""
- local col = pos.character
- table.insert(items, {
- filename = filename,
- lnum = row + 1,
- col = col + 1;
- text = line;
- })
- end
- end
- return items
- end
- --- Fills target window's location list with given list of items.
- --- Can be obtained with e.g. |vim.lsp.util.locations_to_items()|.
- --- Defaults to current window.
- ---
- ---@deprecated Use |setloclist()|
- ---
- ---@param items (table) list of items
- function M.set_loclist(items, win_id)
- vim.api.nvim_echo({{'vim.lsp.util.set_loclist is deprecated. See :h deprecated', 'WarningMsg'}}, true, {})
- vim.fn.setloclist(win_id or 0, {}, ' ', {
- title = 'Language Server';
- items = items;
- })
- end
- --- Fills quickfix list with given list of items.
- --- Can be obtained with e.g. |vim.lsp.util.locations_to_items()|.
- ---
- ---@deprecated Use |setqflist()|
- ---
- ---@param items (table) list of items
- function M.set_qflist(items)
- vim.api.nvim_echo({{'vim.lsp.util.set_qflist is deprecated. See :h deprecated', 'WarningMsg'}}, true, {})
- vim.fn.setqflist({}, ' ', {
- title = 'Language Server';
- items = items;
- })
- end
- -- According to LSP spec, if the client set "symbolKind.valueSet",
- -- the client must handle it properly even if it receives a value outside the specification.
- -- https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_documentSymbol
- function M._get_symbol_kind_name(symbol_kind)
- return protocol.SymbolKind[symbol_kind] or "Unknown"
- end
- --- Converts symbols to quickfix list items.
- ---
- ---@param symbols DocumentSymbol[] or SymbolInformation[]
- function M.symbols_to_items(symbols, bufnr)
- ---@private
- local function _symbols_to_items(_symbols, _items, _bufnr)
- for _, symbol in ipairs(_symbols) do
- if symbol.location then -- SymbolInformation type
- local range = symbol.location.range
- local kind = M._get_symbol_kind_name(symbol.kind)
- table.insert(_items, {
- filename = vim.uri_to_fname(symbol.location.uri),
- lnum = range.start.line + 1,
- col = range.start.character + 1,
- kind = kind,
- text = '['..kind..'] '..symbol.name,
- })
- elseif symbol.selectionRange then -- DocumentSymbole type
- local kind = M._get_symbol_kind_name(symbol.kind)
- table.insert(_items, {
- -- bufnr = _bufnr,
- filename = vim.api.nvim_buf_get_name(_bufnr),
- lnum = symbol.selectionRange.start.line + 1,
- col = symbol.selectionRange.start.character + 1,
- kind = kind,
- text = '['..kind..'] '..symbol.name
- })
- if symbol.children then
- for _, v in ipairs(_symbols_to_items(symbol.children, _items, _bufnr)) do
- for _, s in ipairs(v) do
- table.insert(_items, s)
- end
- end
- end
- end
- end
- return _items
- end
- return _symbols_to_items(symbols, {}, bufnr)
- end
- --- Removes empty lines from the beginning and end.
- ---@param lines (table) list of lines to trim
- ---@returns (table) trimmed list of lines
- function M.trim_empty_lines(lines)
- local start = 1
- for i = 1, #lines do
- if lines[i] ~= nil and #lines[i] > 0 then
- start = i
- break
- end
- end
- local finish = 1
- for i = #lines, 1, -1 do
- if lines[i] ~= nil and #lines[i] > 0 then
- finish = i
- break
- end
- end
- return vim.list_extend({}, lines, start, finish)
- end
- --- Accepts markdown lines and tries to reduce them to a filetype if they
- --- comprise just a single code block.
- ---
- --- CAUTION: Modifies the input in-place!
- ---
- ---@param lines (table) list of lines
- ---@returns (string) filetype or 'markdown' if it was unchanged.
- function M.try_trim_markdown_code_blocks(lines)
- local language_id = lines[1]:match("^```(.*)")
- if language_id then
- local has_inner_code_fence = false
- for i = 2, (#lines - 1) do
- local line = lines[i]
- if line:sub(1,3) == '```' then
- has_inner_code_fence = true
- break
- end
- end
- -- No inner code fences + starting with code fence = hooray.
- if not has_inner_code_fence then
- table.remove(lines, 1)
- table.remove(lines)
- return language_id
- end
- end
- return 'markdown'
- end
- ---@private
- ---@param window (optional, number): window handle or 0 for current, defaults to current
- ---@param offset_encoding string utf-8|utf-16|utf-32|nil defaults to `offset_encoding` of first client of buffer of `window`
- local function make_position_param(window, offset_encoding)
- window = window or 0
- local buf = vim.api.nvim_win_get_buf(window)
- local row, col = unpack(api.nvim_win_get_cursor(window))
- offset_encoding = offset_encoding or M._get_offset_encoding(buf)
- row = row - 1
- local line = api.nvim_buf_get_lines(buf, row, row+1, true)[1]
- if not line then
- return { line = 0; character = 0; }
- end
- col = _str_utfindex_enc(line, col, offset_encoding)
- return { line = row; character = col; }
- end
- --- Creates a `TextDocumentPositionParams` object for the current buffer and cursor position.
- ---
- ---@param window (optional, number): window handle or 0 for current, defaults to current
- ---@param offset_encoding string utf-8|utf-16|utf-32|nil defaults to `offset_encoding` of first client of buffer of `window`
- ---@returns `TextDocumentPositionParams` object
- ---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocumentPositionParams
- function M.make_position_params(window, offset_encoding)
- window = window or 0
- local buf = vim.api.nvim_win_get_buf(window)
- offset_encoding = offset_encoding or M._get_offset_encoding(buf)
- return {
- textDocument = M.make_text_document_params(buf);
- position = make_position_param(window, offset_encoding)
- }
- end
- --- Utility function for getting the encoding of the first LSP client on the given buffer.
- ---@param bufnr (number) buffer handle or 0 for current, defaults to current
- ---@returns (string) encoding first client if there is one, nil otherwise
- function M._get_offset_encoding(bufnr)
- validate {
- bufnr = {bufnr, 'n', true};
- }
- local offset_encoding
- for _, client in pairs(vim.lsp.buf_get_clients(bufnr)) do
- local this_offset_encoding = client.offset_encoding or "utf-16"
- if not offset_encoding then
- offset_encoding = this_offset_encoding
- elseif offset_encoding ~= this_offset_encoding then
- vim.notify("warning: multiple different client offset_encodings detected for buffer, this is not supported yet", vim.log.levels.WARN)
- end
- end
- return offset_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 window (optional, number): window handle or 0 for current, defaults to current
- ---@param offset_encoding string utf-8|utf-16|utf-32|nil defaults to `offset_encoding` of first client of buffer of `window`
- ---@returns { textDocument = { uri = `current_file_uri` }, range = { start =
- ---`current_position`, end = `current_position` } }
- function M.make_range_params(window, offset_encoding)
- local buf = vim.api.nvim_win_get_buf(window)
- offset_encoding = offset_encoding or M._get_offset_encoding(buf)
- local position = make_position_param(window, offset_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 ({number, number}, optional) mark-indexed position.
- ---Defaults to the start of the last visual selection.
- ---@param end_pos ({number, number}, optional) mark-indexed position.
- ---Defaults to the end of the last visual selection.
- ---@param bufnr (optional, number): buffer handle or 0 for current, defaults to current
- ---@param offset_encoding string utf-8|utf-16|utf-32|nil defaults to `offset_encoding` of first client of `bufnr`
- ---@returns { textDocument = { uri = `current_file_uri` }, range = { start =
- ---`start_position`, end = `end_position` } }
- function M.make_given_range_params(start_pos, end_pos, bufnr, offset_encoding)
- validate {
- start_pos = {start_pos, 't', true};
- end_pos = {end_pos, 't', true};
- offset_encoding = {offset_encoding, 's', true};
- }
- bufnr = bufnr or vim.api.nvim_get_current_buf()
- offset_encoding = offset_encoding or M._get_offset_encoding(bufnr)
- local A = list_extend({}, start_pos or api.nvim_buf_get_mark(bufnr, '<'))
- local B = list_extend({}, end_pos or api.nvim_buf_get_mark(bufnr, '>'))
- -- convert to 0-index
- A[1] = A[1] - 1
- B[1] = B[1] - 1
- -- account for offset_encoding.
- if A[2] > 0 then
- A = {A[1], M.character_offset(bufnr, A[1], A[2], offset_encoding)}
- end
- if B[2] > 0 then
- B = {B[1], M.character_offset(bufnr, B[1], B[2], offset_encoding)}
- end
- -- we need to offset the end character position otherwise we loose the last
- -- character of the selection, as LSP end position is exclusive
- -- see https://microsoft.github.io/language-server-protocol/specification#range
- if vim.o.selection ~= 'exclusive' then
- B[2] = B[2] + 1
- end
- return {
- textDocument = M.make_text_document_params(bufnr),
- range = {
- start = {line = A[1], character = A[2]},
- ['end'] = {line = B[1], character = B[2]}
- }
- }
- end
- --- Creates a `TextDocumentIdentifier` object for the current buffer.
- ---
- ---@param bufnr (optional, number): Buffer handle, defaults to current
- ---@returns `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
- ---@param removed
- function M.make_workspace_params(added, removed)
- return { event = { added = added; removed = removed; } }
- end
- --- Returns visual width of tabstop.
- ---
- ---@see |softtabstop|
- ---@param bufnr (optional, number): Buffer handle, defaults to current
- ---@returns (number) tabstop visual width
- function M.get_effective_tabstop(bufnr)
- validate { bufnr = {bufnr, 'n', true} }
- local bo = bufnr and vim.bo[bufnr] or vim.bo
- local sts = bo.softtabstop
- return (sts > 0 and sts) or (sts < 0 and bo.shiftwidth) or bo.tabstop
- end
- --- Creates a `DocumentFormattingParams` object for the current buffer and cursor position.
- ---
- ---@param options Table with valid `FormattingOptions` entries
- ---@returns `DocumentFormattingParams` object
- ---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_formatting
- function M.make_formatting_params(options)
- validate { options = {options, 't', 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.
- ---
- ---@param buf buffer id (0 for current)
- ---@param row 0-indexed line
- ---@param col 0-indexed byte offset in line
- ---@param offset_encoding string utf-8|utf-16|utf-32|nil defaults to `offset_encoding` of first client of `buf`
- ---@returns (number, number) `offset_encoding` index of the character in line {row} column {col} in buffer {buf}
- function M.character_offset(buf, row, col, offset_encoding)
- local line = get_line(buf, row)
- offset_encoding = offset_encoding or M._get_offset_encoding(buf)
- -- If the col is past the EOL, use the line length.
- if col > #line then
- return _str_utfindex_enc(line, nil, offset_encoding)
- end
- return _str_utfindex_enc(line, col, offset_encoding)
- end
- --- Helper function to return nested values in language server settings
- ---
- ---@param settings a table of language server settings
- ---@param section a string indicating the field of the settings table
- ---@returns (table or string) The value of settings accessed via section
- function M.lookup_section(settings, section)
- for part in vim.gsplit(section, '.', true) do
- settings = settings[part]
- if not settings then
- return
- end
- end
- return settings
- end
- M._get_line_byte_from_position = get_line_byte_from_position
- M.buf_versions = {}
- return M
- -- vim:sw=2 ts=2 et
|