-- 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