local NS = vim.api.nvim_create_namespace("ow.lsp.codelens") ---@alias ow.lsp.codelens.Position "eol" | "right_align" | "inline" | "above" ---@type ow.lsp.codelens.Position local position = "above" local separator = " | " ---@class ow.lsp.CodeLens : lsp.CodeLens ---@field command lsp.Command ---@class ow.lsp.codelens.Row ---@field row integer ---@field lenses ow.lsp.CodeLens[] ---@field pending integer local Row = {} Row.__index = Row ---@param row integer ---@return ow.lsp.codelens.Row function Row.new(row) return setmetatable({ row = row, lenses = {}, pending = 0 }, Row) end ---@param lens ow.lsp.CodeLens function Row:add(lens) table.insert(self.lenses, lens) end function Row:expect() self.pending = self.pending + 1 end ---@param lens? lsp.CodeLens function Row:resolve(lens) self.pending = self.pending - 1 if lens and lens.command then self:add(lens) end end ---@return boolean function Row:is_ready() return self.pending == 0 and #self.lenses > 0 end ---@param buf integer function Row:render(buf) if not self:is_ready() or not vim.api.nvim_buf_is_valid(buf) then return end table.sort(self.lenses, function(a, b) return a.range.start.character < b.range.start.character end) local parts = {} for i, lens in ipairs(self.lenses) do table.insert(parts, { lens.command.title, "LspCodeLens" }) if i < #self.lenses then table.insert(parts, { separator, "LspCodeLensSeparator" }) end end ---@type vim.api.keyset.set_extmark local opts local col if position == "above" then local line = vim.api.nvim_buf_get_lines( buf, self.row, self.row + 1, false )[1] or "" local indent = line:match("^%s*") or "" local chunks = parts if indent ~= "" then chunks = { { indent, "LspCodeLensSeparator" } } vim.list_extend(chunks, parts) end col = 0 opts = { virt_lines = { chunks }, virt_lines_above = true, hl_mode = "combine", } else local first = assert(self.lenses[1]) col = position == "inline" and first.range.start.character or 0 opts = { virt_text = parts, virt_text_pos = position, hl_mode = "combine", } end -- One extmark per row. Extmarks auto-shift with edits, so multiple from -- prior refreshes can end up on the same row; pick the first and drop -- the rest, then reuse its id for an atomic update. local marks = vim.api.nvim_buf_get_extmarks( buf, NS, { self.row, 0 }, { self.row, -1 }, { details = true } ) ---@type { --- id: integer, --- col: integer, --- details: vim.api.keyset.extmark_details? ---} local primary for i, mark in ipairs(marks) do if i == 1 then primary = { id = mark[1], col = mark[3], details = mark[4] } else vim.api.nvim_buf_del_extmark(buf, NS, mark[1]) end end if primary and primary.col == col and primary.details then local d = primary.details local same = opts.virt_lines and vim.deep_equal(d.virt_lines, opts.virt_lines) and d.virt_lines_above == opts.virt_lines_above or not opts.virt_lines and d.virt_text_pos == opts.virt_text_pos and vim.deep_equal(d.virt_text, opts.virt_text) if same then return end end opts.id = primary and primary.id or nil vim.api.nvim_buf_set_extmark(buf, NS, self.row, col, opts) end ---@param buf integer function Row.clear_all(buf) vim.api.nvim_buf_clear_namespace(buf, NS, 0, -1) end ---@param buf integer ---@param rows table function Row.prune(buf, rows) for _, mark in ipairs(vim.api.nvim_buf_get_extmarks(buf, NS, 0, -1, {})) do if not rows[mark[2]] then vim.api.nvim_buf_del_extmark(buf, NS, mark[1]) end end end ---@class ow.lsp.codelens.row.ConfigureOpts ---@field position? ow.lsp.codelens.Position ---@field separator? string ---@param opts ow.lsp.codelens.row.ConfigureOpts function Row.configure(opts) if opts.position ~= nil then position = opts.position end if opts.separator ~= nil then separator = opts.separator end end return Row