lewis6991/hover.nvim

Proposal: Allow provider to mess with the hover buffer/window

Opened this issue · 1 comments

Some hover providers (like rust-analyzer) allow clients to have certain actions embedded in the hover, which would need providers to be able to modify the buffer and window (set keymaps, change window options) etc. So, I think allowing providers the ability to do that is a good idea.

Proposed Change

Allow providers to define a on_render function which is called when the hover is rendered.

Example Implementation

diff --git a/lua/hover/actions.lua b/lua/hover/actions.lua
index 65d8cc5..321c269 100644
--- a/lua/hover/actions.lua
+++ b/lua/hover/actions.lua
@@ -66,11 +66,13 @@ local function focus_or_close_hover()
 end
 
 local function show_hover(provider_id, config, result, opts)
-  local _, winnr = util.open_floating_preview(result.lines, result.filetype, opts)
+  local bufnr, winnr = util.open_floating_preview(result.lines, result.filetype, opts)
 
   if config.title then
     add_title(winnr, provider_id)
   end
+
+  return bufnr, winnr
 end
 
 -- Must be called in async context
@@ -89,7 +91,10 @@ local function run_provider(provider)
   local result = provider.execute()
   if result then
     async.scheduler()
-    show_hover(provider.id, config, result, opts)
+    local bufnr, winnr = show_hover(provider.id, config, result, opts)
+    if provider.on_render then
+        provider.on_render(bufnr, winnr)
+    end
     return true
   end

Example provider implementation (rust-tools hover actions)

---@diagnostic disable: missing-parameter, param-type-mismatch
local M = {}
M._state = { commands = nil }

local function execute_rust_analyzer_command(action, ctx)
  local fn = vim.lsp.commands[action.command]
  if fn then
    fn(action, ctx)
  end
end

-- run the command under the cursor, if the thing under the cursor is not the
-- command then do nothing
local function run_command(ctx)
  local winnr = vim.api.nvim_get_current_win()
  local line = vim.api.nvim_win_get_cursor(winnr)[1]

  if line > #M._state.commands then
    return
  end

  local action = M._state.commands[line]

  vim.api.nvim_win_close(winnr, true)
  execute_rust_analyzer_command(action, ctx)
end

local function parse_commands()
  local prompt = {}

  for i, value in ipairs(M._state.commands) do
    if value.command == "rust-analyzer.gotoLocation" then
      table.insert(
        prompt,
        string.format("%d. Go to %s (%s)", i, value.title, value.tooltip)
      )
    elseif value.command == "rust-analyzer.showReferences" then
      table.insert(prompt, string.format("%d. %s", i, "Go to " .. value.title))
    else
      table.insert(prompt, string.format("%d. %s", i, value.title))
    end
  end

  return prompt
end

require("hover").register({
  name = "Rust Hover Actions",
  enabled = function()
    return true
  end,
  execute = function(done)
    local util = require("vim.lsp.util")
    local params = util.make_position_params()
    vim.lsp.buf_request(
      0,
      "textDocument/hover",
      params,
      function(_, result, ctx)
        if not result or not result.contents then
          done()
          return
        end

        M._state.commands = nil

        local lines = util.convert_input_to_markdown_lines(result.contents)
        if result.actions then
          M._state.commands = result.actions[1].commands
          local prompt = parse_commands()
          local l = {}

          for _, value in ipairs(prompt) do
            table.insert(l, value)
          end

          lines = vim.list_extend(l, lines)
        end

        lines = util.trim_empty_lines(lines)

        M._state.ctx = ctx

        if vim.tbl_isempty(lines) then
          done()
          return
        end

        done({ lines = lines, filetype = "markdown" })
      end
    )
  end,
  on_render = function(bufnr, winnr)
    if M._state.commands == nil then
      return
    end
    -- makes more sense in a dropdown-ish ui
    vim.api.nvim_win_set_option(winnr, "cursorline", true)

    -- run the command under the cursor
    vim.keymap.set("n", "<CR>", function()
      run_command(M._state.ctx)
    end, { buffer = bufnr, noremap = true, silent = true })
  end,
})

return M

I had a use case for this where the hover output was colored using ANSI sequences and I wanted to render those correctly.
Here is how I did it, might be helpful:

When you call the done callback pass a custom filetype that ideally is not used anywhere else:

done { lines = job:result(), filetype = 'glow' }

Internally, the syntax will be set to this filetype, so using the following snippet you can run code once it is opened:

vim.api.nvim_create_autocmd('Syntax', {
  pattern = "glow",
  callback = function(ctx)
    vim.schedule(function()
      vim.api.nvim_buf_set_option(ctx.buf, 'modifiable', true)

      -- Do your stuff here.

      vim.api.nvim_buf_set_option(ctx.buf, 'modifiable', false)
    end)
  end,
})

Full working example of my use case (GitHub repos rendered with charmbracelet/glow, converted using m00qek/baleia.nvim):

local hover = require('hover')
local Job = require 'plenary.job'
local baleia = require('baleia').setup {}

local repo_pattern = '[^%s]+/[^%s]+'

vim.api.nvim_create_autocmd(
  'Syntax', {
  pattern = 'glow',
  callback = function(ctx)
    vim.schedule(
      function()
        vim.api.nvim_buf_set_option(ctx.buf, 'modifiable', true)
        baleia.once(ctx.buf)
        vim.api.nvim_buf_set_option(ctx.buf, 'modifiable', false)
      end
    )
  end,
}
)

local function enabled()
  return vim.fn.expand('<cfile>'):match(repo_pattern) ~= nil
end

local function execute(done)
  local repo = vim.fn.expand('<cfile>'):match(repo_pattern)

  Job:new(
    {
      command = 'glow',
      args = { 'github.com/' .. repo, '-s', 'dark' },
      on_exit = function(job)
        done { lines = job:result(), filetype = 'glow' }
      end,
    }
  ):start()
end

hover.register({
  name = "GitHub repos",
  priority = 1050,
  enabled = enabled,
  execute = execute,
})