local api = vim.api local function get_parameter_label(result) local signatures = result.signatures local activeSignature = result.activeSignature or 0 activeSignature = activeSignature + 1 local signature = signatures[activeSignature] if signature == nil or signature.parameters == nil then -- no parameter return "" end local activeParameter = signature.activeParameter or result.activeParameter if activeParameter == nil or activeParameter < 0 then return "" end if signature.parameters == nil then return "" end if activeParameter >= #signature.parameters then activeParameter = #signature.parameters - 1 end if signature.parameters[activeParameter + 1] == nil then return "" end local param = signature.parameters[activeParameter + 1].label local param_str -- Handle both string and table parameter labels if type(param) == "table" then local s = param[1] local e = param[2] param_str = string.sub(signature.label, s, e) elseif type(param) == "string" then param_str = signature.label end return param_str end ---@class opts table ---@field brk_chars string string of characters to break lines at ---@field indent_str string string to indent wrapped lines with ---@param str string string to wrap into lines ---@param maxlen integer maximum length of lines ---@return table table of wrapped lines local function _wrap_lines(str, maxlen, opts) local atoms = {} opts = opts or {} local brk_chars = opts.brk_chars or ",()" local indent_str = opts.indent_str or " " local end_char = string.sub(brk_chars, 1, 1) str = str .. end_char -- add break char at end of string, so we can match the end of the line for w in string.gmatch(str, "[^" .. brk_chars .. "]*" .. "[" .. brk_chars .. "]") do table.insert(atoms, w) end atoms[#atoms] = string.gsub(atoms[#atoms], end_char .. "$", "") -- remove break char at end local lines = {} lines[1] = "" local line_num = 1 local padding = opts.padding or 2 local is = "" for i, w in ipairs(atoms) do if line_num == 1 then is = "" else is = indent_str end if string.len(lines[line_num]) + string.len(w) + (padding * 2) <= maxlen then lines[line_num] = lines[line_num] .. w else lines[line_num] = string.rep(" ", padding) .. is .. lines[line_num] .. string.rep(" ", padding) --add padding line_num = line_num + 1 lines[line_num] = string.gsub(w, "^%s", "") end if i == #atoms then lines[line_num] = string.rep(" ", padding) .. is .. lines[line_num] .. string.rep(" ", padding) --add padding to last line end end return lines end --@param signature_help string[] --@param maxlen integer local function make_signature_lines(signature_help, maxlen) if not signature_help.signatures then return end local lines = {} local active_signature = signature_help.activeSignature or 0 if active_signature >= #signature_help.signatures or active_signature < 0 then active_signature = 0 end local signature = signature_help.signatures[active_signature + 1] if not signature then return end vim.list_extend(lines, vim.split(signature.label, "\n", { plain = true, trimempty = true })) local wrapped_lines = {} for _, ll in ipairs(lines) do vim.list_extend(wrapped_lines, _wrap_lines(ll, maxlen)) end return wrapped_lines end local function make_doc_lines(result, ft) local active_signature = result.activeSignature or 0 local signature = result.signatures[active_signature + 1] local doc_lines = {} if not signature then return end if signature.documentation 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(signature.documentation) == "string" then signature.documentation = { kind = "plaintext", value = signature.documentation } end vim.lsp.util.convert_input_to_markdown_lines(signature.documentation, doc_lines) end for _, parameter in ipairs(signature.parameters) do if parameter.documentation then vim.lsp.util.convert_input_to_markdown_lines(parameter.documentation, doc_lines) end end return doc_lines end local _make_floating_popup_size = function(contents, opts) opts = opts or {} local max_width = opts.max_width or 80 local max_height = opts.max_height or 10 local line_widths = {} local width = 0 for i, line in ipairs(contents) do line_widths[i] = vim.fn.strdisplaywidth(line:gsub("%z", "\n")) width = math.max(line_widths[i], width) end -- local border_width = get_border_size(opts).width local border_width = 2 --TODO: do we need to deal with this programmatically? local screen_width = api.nvim_win_get_width(0) width = math.min(width, screen_width) width = math.min(width, max_width) -- make sure borders are always inside the screen if width + border_width > screen_width then width = screen_width - border_width end local height = #contents 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.ceil(line_width / max_width) end else for i = 1, #contents do height = height + math.max(1, math.ceil(line_widths[i] / max_width)) end end if max_height then height = math.min(height, max_height) end return width, height end ---@param sig_content table|nil signature window content ---@param doc_content table|nil documentation window content ---@param opts table window options ---@returns (table) Options local function _make_floating_popup_options(sig_content, doc_content, opts) local sig_width, sig_height = _make_floating_popup_size(sig_content, opts) local doc_width, doc_height = _make_floating_popup_size(doc_content, opts) local border_height = 2 --TODO: get this from opts local offset_y = opts.offset_y or 0 local offset_x = opts.offset_x or 0 local anchor_bias = opts.anchor_bias or "auto" local relative = "win" local sig_anchor local doc_anchor local sig_row, sig_col local doc_row, doc_col local curs_row = vim.fn.winline() local curs_col = vim.fn.wincol() local lines_above = curs_row - 1 local lines_below = vim.fn.winheight(0) - lines_above if -- figure out signature anchor and height lines_above < sig_height + border_height or (anchor_bias == "below" and lines_below > sig_height + border_height + offset_y) or (anchor_bias == "auto" and lines_below > lines_above) then sig_anchor = "N" sig_height = math.min(lines_below - offset_y, sig_height) sig_row = curs_row + offset_y else sig_anchor = "S" sig_height = math.min(lines_above - offset_y, sig_height) sig_row = curs_row - 1 - offset_y end if sig_anchor == "N" then lines_below = lines_below - (sig_height + border_height) --new lines_below including sig else lines_above = lines_above - (sig_height + border_height) --new lines_above including sig end if -- figure out documentation anchor and height lines_above < doc_height + border_height or (anchor_bias == "below" and lines_below > doc_height + border_height) or (anchor_bias == "auto" and lines_below > lines_above) then doc_anchor = "N" doc_height = math.min(lines_below, doc_height) if sig_anchor == "N" then --docs and sigs below doc_row = curs_row + offset_y + (sig_height + border_height) - 1 else --docs above sigs below doc_row = curs_row - 1 - offset_y end else doc_anchor = "S" doc_height = math.min(lines_above, doc_height) if sig_anchor == "S" then --docs and sigs above doc_row = curs_row - (offset_y + sig_height + border_height) else --docs below sigs above doc_row = curs_row - 1 - offset_y end end local wincol = vim.fn.wincol() if wincol + sig_width + offset_x <= vim.o.columns then sig_anchor = sig_anchor .. "W" doc_anchor = doc_anchor .. "W" sig_col = curs_col - wincol doc_col = curs_col - wincol else sig_anchor = sig_anchor .. "E" doc_anchor = doc_anchor .. "E" sig_col = curs_col + 1 - wincol doc_col = curs_col + 1 - wincol end local first_col = vim.fn.getwininfo(vim.fn.win_getid())[1].textoff return { --signature window options anchor = sig_anchor, row = sig_row, col = sig_col + first_col + offset_x, width = sig_width, height = sig_height, focusable = opts.focusable, relative = relative, style = "minimal", border = opts.border or "rounded", zindex = opts.zindex or 50, -- winblend = opts.winblend or 10, }, { --documentation window options anchor = doc_anchor, row = doc_row, col = doc_col + first_col + offset_x, width = doc_width, height = doc_height, focusable = opts.focusable, relative = relative, style = "minimal", border = opts.border or "rounded", zindex = opts.zindex or 50, -- winblend = opts.winblend or 10, } end ---@param result string[] signature help result ---@param syntax string syntax language for highlighting. i.e. "lua" or "markdown" ---@param opts table window options ---@return function function to open signature window ---@return function function to open documentation window local function create_float_windows(result, syntax, opts) local npcall = vim.F.npcall vim.validate({ result = { result, "t" }, syntax = { syntax, "s", true }, opts = { opts, "t", true }, }) opts = opts or {} local bufnr = api.nvim_get_current_buf() local existing_sig_win = npcall(api.nvim_buf_get_var, bufnr, "lsp_sig_win") local existing_doc_win = npcall(api.nvim_buf_get_var, bufnr, "lsp_doc_win") if existing_sig_win and api.nvim_win_is_valid(existing_sig_win) then api.nvim_win_close(existing_sig_win, true) end if existing_doc_win and api.nvim_win_is_valid(existing_doc_win) then api.nvim_win_close(existing_doc_win, true) end -- Create the buffers local sig_bufnr = api.nvim_create_buf(false, true) local doc_bufnr = api.nvim_create_buf(false, true) syntax = syntax or "text" vim.bo[sig_bufnr].syntax = syntax vim.bo[sig_bufnr].ft = syntax vim.treesitter.start(sig_bufnr) vim.bo[doc_bufnr].syntax = "markdown" vim.bo[doc_bufnr].ft = "markdown" vim.treesitter.start(doc_bufnr) local sig_content = make_signature_lines(result, opts.max_width) local doc_content = make_doc_lines(result, syntax) ---@diagnostic disable-next-line: param-type-mismatch api.nvim_buf_set_lines(sig_bufnr, 0, -1, true, sig_content) ---@diagnostic disable-next-line: param-type-mismatch api.nvim_buf_set_lines(doc_bufnr, 0, -1, true, doc_content) local sig_float_options, doc_float_options = _make_floating_popup_options(sig_content, doc_content, opts) return function() local sig_winnr = api.nvim_open_win(sig_bufnr, false, sig_float_options) vim.wo[sig_winnr].conceallevel = 2 vim.wo[sig_winnr].foldenable = false vim.wo[sig_winnr].winblend = opts.winblend vim.bo[sig_bufnr].modifiable = false vim.bo[sig_bufnr].bufhidden = "hide" -- save focus_id api.nvim_buf_set_var(bufnr, "lsp_sig_win", sig_winnr) return sig_bufnr, sig_winnr, sig_content end, function() local doc_winnr = api.nvim_open_win(doc_bufnr, false, doc_float_options) vim.wo[doc_winnr].conceallevel = 2 vim.wo[doc_winnr].foldenable = false vim.wo[doc_winnr].winblend = opts.winblend vim.bo[doc_bufnr].modifiable = false vim.bo[doc_bufnr].bufhidden = "hide" -- save focus_id api.nvim_buf_set_var(doc_bufnr, "lsp_doc_win", doc_winnr) return doc_bufnr, doc_winnr end end local function close_float_window(winnr) if winnr and api.nvim_win_is_valid(winnr) then pcall(api.nvim_win_close, winnr, true) end end local function delete_buffer(bufnr) if bufnr and api.nvim_buf_is_valid(bufnr) then pcall(api.nvim_buf_delete, bufnr, { force = true }) end end return { get_parameter_label = get_parameter_label, create_float_windows = create_float_windows, close_float_window = close_float_window, delete_buffer = delete_buffer, }