Files
nvim-config/lua/clje/hover.lua
2026-04-30 10:27:39 -04:00

219 lines
6.0 KiB
Lua

-- Custom hover for CljElixir .clje files
-- Resolves Module/function patterns against stub files in the project's stubs/ directory
local M = {}
--- Walk up from the current buffer's directory to find the stubs/ directory
local function find_stubs_dir()
local bufpath = vim.api.nvim_buf_get_name(0)
if bufpath == "" then return nil end
local dir = vim.fn.fnamemodify(bufpath, ":p:h")
for _ = 1, 20 do
if dir == "/" or dir == "" then break end
if vim.fn.isdirectory(dir .. "/stubs") == 1 then
return dir .. "/stubs"
end
dir = vim.fn.fnamemodify(dir, ":h")
end
return nil
end
--- Extract the symbol under cursor, handling namespace/function patterns.
--- Returns (namespace, function_name) or nil.
local function symbol_under_cursor()
local line = vim.api.nvim_get_current_line()
local col = vim.api.nvim_win_get_cursor(0)[2] -- 0-indexed byte col
-- Expand outward from cursor to find the full symbol (letters, digits, -, ?, !, ., /, _)
local s, e = col + 1, col + 1
local sym_chars = "[%w%-%?%!%.%/_><=*+]"
while s > 1 and line:sub(s - 1, s - 1):match(sym_chars) do
s = s - 1
end
while e < #line and line:sub(e + 1, e + 1):match(sym_chars) do
e = e + 1
end
local word = line:sub(s, e)
if not word or word == "" then return nil, nil end
-- Must contain a / to be a qualified call
local slash = word:find("/")
if not slash then return nil, nil end
local ns = word:sub(1, slash - 1)
local fn_name = word:sub(slash + 1)
if ns == "" or fn_name == "" then return nil, nil end
return ns, fn_name
end
--- Resolve a namespace name to a stub file path
local function resolve_stub_file(stubs_dir, ns)
-- Direct match (e.g., Process.clj, erlang.clj)
local path = stubs_dir .. "/" .. ns .. ".clj"
if vim.fn.filereadable(path) == 1 then return path end
-- erl_ prefix for case-insensitive collisions (e.g., erl_io.clj for :io)
path = stubs_dir .. "/erl_" .. ns .. ".clj"
if vim.fn.filereadable(path) == 1 then return path end
-- Subdirectory (e.g., clje/core.clj)
path = stubs_dir .. "/clje/" .. ns .. ".clj"
if vim.fn.filereadable(path) == 1 then return path end
return nil
end
--- Find a (defn ...) or (defmacro ...) form in the file lines and return its text
local function find_defn(lines, fn_name)
-- Escape special pattern chars in the function name
local pat = fn_name:gsub("([%-%.%?%!%*%+%[%]%(%)%^%$%%])", "%%%1")
local start_idx = nil
for i, line in ipairs(lines) do
-- Match (defn name or (defmacro name at the start of a line
if line:match("^%(defn%s+" .. pat .. "$")
or line:match("^%(defn%s+" .. pat .. "%s")
or line:match("^%(defmacro%s+" .. pat .. "$")
or line:match("^%(defmacro%s+" .. pat .. "%s") then
start_idx = i
break
end
end
if not start_idx then return nil end
-- Collect lines for this form by tracking paren depth
local form_lines = {}
local depth = 0
for i = start_idx, #lines do
local line = lines[i]
table.insert(form_lines, line)
for ch in line:gmatch(".") do
if ch == "(" then depth = depth + 1
elseif ch == ")" then depth = depth - 1 end
end
if depth <= 0 and i > start_idx then break end
end
return form_lines
end
--- Extract a vector [args] from a signature line, stripping outer parens and trailing junk
local function extract_sig(s)
-- Match the innermost [args] vector
local vec = s:match("%[(.-)%]")
if vec then
return "[" .. vec .. "]"
end
return nil
end
--- Extract docstring and signatures from defn form lines for a cleaner display
local function parse_defn(form_lines, ns, fn_name)
local md = {}
local docstring_parts = {}
local signatures = {}
local in_docstring = false
for i, line in ipairs(form_lines) do
if i == 1 then goto continue end -- skip (defn name line
local trimmed = line:match("^%s*(.-)%s*$")
-- Detect docstring (starts and/or ends with ")
if not in_docstring and trimmed:match('^"') then
in_docstring = true
-- Single-line docstring
if trimmed:match('^"(.*)"$') then
table.insert(docstring_parts, trimmed:match('^"(.*)"$'))
in_docstring = false
else
table.insert(docstring_parts, trimmed:match('^"(.*)$'))
end
elseif in_docstring then
if trimmed:match('"[%)]*$') then
table.insert(docstring_parts, trimmed:match('^(.-)"'))
in_docstring = false
else
table.insert(docstring_parts, trimmed)
end
elseif trimmed:match("%[") then
-- Signature line containing [args]
local sig = extract_sig(trimmed)
if sig then
table.insert(signatures, sig)
end
end
::continue::
end
-- Build markdown
table.insert(md, "### `" .. ns .. "/" .. fn_name .. "`")
table.insert(md, "")
if #signatures > 0 then
table.insert(md, "```clojure")
for _, sig in ipairs(signatures) do
if sig == "[]" then
table.insert(md, "(" .. ns .. "/" .. fn_name .. ")")
else
table.insert(md, "(" .. ns .. "/" .. fn_name .. " " .. sig .. ")")
end
end
table.insert(md, "```")
table.insert(md, "")
end
if #docstring_parts > 0 then
for _, part in ipairs(docstring_parts) do
table.insert(md, part)
end
end
return md
end
--- Main hover function — looks up Module/function in stubs
function M.hover()
local ns, fn_name = symbol_under_cursor()
if not ns or not fn_name then
vim.lsp.buf.hover()
return
end
local stubs_dir = find_stubs_dir()
if not stubs_dir then
vim.lsp.buf.hover()
return
end
local stub_file = resolve_stub_file(stubs_dir, ns)
if not stub_file then
vim.lsp.buf.hover()
return
end
local lines = vim.fn.readfile(stub_file)
if not lines or #lines == 0 then
vim.lsp.buf.hover()
return
end
local form_lines = find_defn(lines, fn_name)
if not form_lines then
-- Function not found in stub, fall back to LSP
vim.lsp.buf.hover()
return
end
local md = parse_defn(form_lines, ns, fn_name)
vim.lsp.util.open_floating_preview(md, "markdown", {
border = "rounded",
focus_id = "clje-hover",
})
end
return M