Last active
July 20, 2025 21:07
-
-
Save wroyca/8678c2344af6852752915568e9a3db6e to your computer and use it in GitHub Desktop.
Creates clickable extmarks for document link ranges
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
---@meta document-link | |
---@class DocumentLinkHandler | |
---@field private _namespace_id number | |
---@field private _augroup_id number | |
---@field private _document_links table<number, table<string, DocumentLinkData>> | |
---@field private _config DocumentLinkConfig | |
---@field private _logger Logger | |
local DocumentLinkHandler = {} | |
---@class DocumentLinkConfig | |
---@field enabled boolean Enable/disable document links | |
---@field debounce_ms number Debounce time for requests in milliseconds | |
---@field max_links_per_buffer number Maximum number of links per buffer | |
---@field auto_request boolean Automatically request links on events | |
---@field events string[] Events that trigger link requests | |
---@field highlight_group string Highlight group for document links | |
---@field priority number Extmark priority | |
---@field resolve_links boolean Whether to resolve links | |
---@field log_level number Logging level (vim.log.levels) | |
---@class DocumentLinkData | |
---@field target string The URI target of the link | |
---@field range DocumentLinkRange The range of the link in the document | |
---@field extmark_id number|nil The ID of the associated extmark | |
---@class DocumentLinkRange | |
---@field start_line number 0-indexed line number | |
---@field start_char number 0-indexed character position | |
---@field end_line number 0-indexed line number | |
---@field end_char number 0-indexed character position | |
---@class LspDocumentLink | |
---@field range LspRange The range this link applies to | |
---@field target string|nil The URI this link points to | |
---@field tooltip string|nil An optional tooltip text when hovering over this link | |
---@field data any|nil A data entry field that is preserved on a document link between a DocumentLinkRequest and a DocumentLinkResolveRequest | |
---@class LspRange | |
---@field start LspPosition The range's start position | |
---@field ["end"] LspPosition The range's end position | |
---@class LspPosition | |
---@field line number Line position (0-indexed) | |
---@field character number Character offset on a line (0-indexed) | |
---@class Logger | |
---@field error fun(self: Logger, message: string): nil | |
---@field warn fun(self: Logger, message: string): nil | |
---@field info fun(self: Logger, message: string): nil | |
---@field debug fun(self: Logger, message: string): nil | |
---@field trace fun(self: Logger, message: string): nil | |
---@type Logger | |
local Logger = {} | |
function Logger:error(message) | |
if self._config.log_level <= vim.log.levels.ERROR then | |
vim.notify("[DocumentLink] ERROR: " .. message, vim.log.levels.ERROR) | |
end | |
end | |
function Logger:warn(message) | |
if self._config.log_level <= vim.log.levels.WARN then | |
vim.notify("[DocumentLink] WARN: " .. message, vim.log.levels.WARN) | |
end | |
end | |
function Logger:info(message) | |
if self._config.log_level <= vim.log.levels.INFO then | |
vim.notify("[DocumentLink] INFO: " .. message, vim.log.levels.INFO) | |
end | |
end | |
function Logger:debug(message) | |
if self._config.log_level <= vim.log.levels.DEBUG then | |
vim.notify("[DocumentLink] DEBUG: " .. message, vim.log.levels.DEBUG) | |
end | |
end | |
function Logger:trace(message) | |
if self._config.log_level <= vim.log.levels.TRACE then | |
vim.notify("[DocumentLink] TRACE: " .. message, vim.log.levels.TRACE) | |
end | |
end | |
---Default configuration for document links | |
---@type DocumentLinkConfig | |
local DEFAULT_CONFIG = { | |
enabled = true, | |
debounce_ms = 100, | |
max_links_per_buffer = 500, | |
auto_request = true, | |
events = { "BufReadPost", "BufWritePost", "CursorHold" }, | |
highlight_group = "Underlined", | |
priority = 100, | |
resolve_links = true, | |
log_level = vim.log.levels.WARN, | |
} | |
---Create a new DocumentLinkHandler instance | |
---@param config DocumentLinkConfig|nil Optional configuration overrides | |
---@return DocumentLinkHandler | |
function DocumentLinkHandler:new(config) | |
config = config or {} | |
---@type DocumentLinkHandler | |
local instance = { | |
_namespace_id = vim.api.nvim_create_namespace("lsp_document_links"), | |
_augroup_id = vim.api.nvim_create_augroup("LspDocumentLinks", { clear = true }), | |
_document_links = {}, | |
_config = vim.tbl_deep_extend("force", DEFAULT_CONFIG, config), | |
_logger = setmetatable({ _config = vim.tbl_deep_extend("force", DEFAULT_CONFIG, config) }, { __index = Logger }), | |
} | |
setmetatable(instance, { __index = self }) | |
return instance | |
end | |
---Validate LSP document link object | |
---@param link any The link object to validate | |
---@return boolean valid True if the link is valid | |
---@return string|nil error_msg Error message if invalid | |
function DocumentLinkHandler:_validate_link(link) | |
if type(link) ~= "table" then | |
return false, "Link must be a table" | |
end | |
if not link.range then | |
return false, "Link must have a range" | |
end | |
if not link.range.start or not link.range["end"] then | |
return false, "Link range must have start and end positions" | |
end | |
local start_pos = link.range.start | |
local end_pos = link.range["end"] | |
if type(start_pos.line) ~= "number" or type(start_pos.character) ~= "number" then | |
return false, "Link range start position must have numeric line and character" | |
end | |
if type(end_pos.line) ~= "number" or type(end_pos.character) ~= "number" then | |
return false, "Link range end position must have numeric line and character" | |
end | |
-- Target is optional for unresolved links | |
if link.target and type(link.target) ~= "string" then | |
return false, "Link target must be a string if present" | |
end | |
return true, nil | |
end | |
---Extract text from document range | |
---@param bufnr number Buffer number | |
---@param range DocumentLinkRange Link range | |
---@return string|nil text The extracted text or nil on failure | |
function DocumentLinkHandler:_extract_link_text(bufnr, range) | |
local lines = vim.api.nvim_buf_get_lines(bufnr, range.start_line, range.end_line + 1, false) | |
if #lines == 0 then | |
return nil | |
end | |
if range.start_line == range.end_line then | |
local line = lines[1] | |
if #line < range.end_char then | |
return nil | |
end | |
return string.sub(line, range.start_char + 1, range.end_char) | |
else | |
-- Multi-line link (extract only first line for simplicity) | |
local line = lines[1] | |
return string.sub(line, range.start_char + 1) | |
end | |
end | |
---Create an extmark for a document link | |
---@param bufnr number Buffer number | |
---@param link LspDocumentLink LSP DocumentLink object | |
---@return number|nil extmark_id The ID of the created extmark or nil on failure | |
function DocumentLinkHandler:_create_link_extmark(bufnr, link) | |
local valid, error_msg = self:_validate_link(link) | |
if not valid then | |
self._logger:warn("Invalid link: " .. (error_msg or "unknown error")) | |
return nil | |
end | |
if not link.target then | |
self._logger:debug("Link has no target, skipping extmark creation") | |
return nil | |
end | |
local range = link.range | |
local start_line, start_char = range.start.line, range.start.character | |
local end_line, end_char = range["end"].line, range["end"].character | |
local document_range = { | |
start_line = start_line, | |
start_char = start_char, | |
end_line = end_line, | |
end_char = end_char, | |
} | |
local link_text = self:_extract_link_text(bufnr, document_range) | |
if not link_text then | |
self._logger:warn("Failed to extract link text for range") | |
return nil | |
end | |
self._logger:debug("Creating extmark for link: " .. link.target) | |
self._logger:trace("Link text: '" .. link_text .. "'") | |
local opts = { | |
end_line = end_line, | |
end_col = end_char, | |
hl_group = self._config.highlight_group, | |
priority = self._config.priority, | |
url = link.target, | |
} | |
if link.tooltip then | |
opts.virt_text = { { " " .. link.tooltip, "Comment" } } | |
opts.virt_text_pos = "eol" | |
end | |
local extmark_id = vim.api.nvim_buf_set_extmark(bufnr, self._namespace_id, start_line, start_char, opts) | |
if extmark_id then | |
self._logger:trace("Created extmark with ID: " .. tostring(extmark_id)) | |
else | |
self._logger:warn("Failed to create extmark") | |
end | |
return extmark_id | |
end | |
---Store document link data for a buffer | |
---@param bufnr number Buffer number | |
---@param link LspDocumentLink Document link | |
---@param extmark_id number|nil Extmark ID | |
function DocumentLinkHandler:_store_link_data(bufnr, link, extmark_id) | |
if not self._document_links[bufnr] then | |
self._document_links[bufnr] = {} | |
end | |
local range = link.range | |
local key = range.start.line .. ":" .. range.start.character | |
self._document_links[bufnr][key] = { | |
target = link.target, | |
range = { | |
start_line = range.start.line, | |
start_char = range.start.character, | |
end_line = range["end"].line, | |
end_char = range["end"].character, | |
}, | |
extmark_id = extmark_id, | |
} | |
end | |
---Clear all document links for a buffer | |
---@param bufnr number Buffer number | |
function DocumentLinkHandler:_clear_buffer_links(bufnr) | |
if not vim.api.nvim_buf_is_valid(bufnr) then | |
self._logger:warn("Attempted to clear links for invalid buffer: " .. bufnr) | |
return | |
end | |
vim.api.nvim_buf_clear_namespace(bufnr, self._namespace_id, 0, -1) | |
self._document_links[bufnr] = {} | |
self._logger:debug("Cleared all links for buffer " .. bufnr) | |
end | |
---Process document links response from LSP server | |
---@param err table|nil Error object | |
---@param result LspDocumentLink[]|nil Array of document links | |
---@param ctx table LSP context | |
---@param config table LSP handler configuration | |
function DocumentLinkHandler:handle_document_links(err, result, ctx, config) | |
if err then | |
local error_message = err.message or tostring(err) | |
self._logger:error("Document links request failed: " .. error_message) | |
require("vim.lsp.log").error("Document links error: " .. error_message) | |
return | |
end | |
local bufnr = ctx.bufnr | |
if not vim.api.nvim_buf_is_valid(bufnr) then | |
self._logger:warn("Received document links for invalid buffer: " .. tostring(bufnr)) | |
return | |
end | |
self._logger:debug("Processing document links response for buffer " .. bufnr) | |
if not result or #result == 0 then | |
self._logger:debug("No document links received") | |
self:_clear_buffer_links(bufnr) | |
return | |
end | |
local link_count = #result | |
self._logger:info("Received " .. link_count .. " document links") | |
if link_count > self._config.max_links_per_buffer then | |
self._logger:warn("Too many links (" .. link_count .. "), limiting to " .. self._config.max_links_per_buffer) | |
local limited_result = {} | |
for i = 1, self._config.max_links_per_buffer do | |
table.insert(limited_result, result[i]) | |
end | |
result = limited_result | |
end | |
self:_clear_buffer_links(bufnr) | |
local created_count = 0 | |
for _, link in ipairs(result) do | |
local extmark_id = self:_create_link_extmark(bufnr, link) | |
self:_store_link_data(bufnr, link, extmark_id) | |
if extmark_id then | |
created_count = created_count + 1 | |
end | |
end | |
self._logger:info("Successfully created " .. created_count .. " document link extmarks") | |
end | |
---Request document links from LSP clients | |
---@param bufnr number|nil Buffer number (defaults to current buffer) | |
function DocumentLinkHandler:request_document_links(bufnr) | |
bufnr = bufnr or vim.api.nvim_get_current_buf() | |
if not vim.api.nvim_buf_is_valid(bufnr) then | |
self._logger:warn("Cannot request document links for invalid buffer: " .. tostring(bufnr)) | |
return | |
end | |
local clients = vim.lsp.get_clients({ bufnr = bufnr }) | |
if #clients == 0 then | |
self._logger:debug("No LSP clients attached to buffer " .. bufnr) | |
return | |
end | |
self._logger:debug("Checking " .. #clients .. " clients for document link support") | |
local providers_found = 0 | |
for _, client in ipairs(clients) do | |
local capabilities = client.server_capabilities | |
if capabilities and capabilities.documentLinkProvider then | |
providers_found = providers_found + 1 | |
self._logger:debug("Requesting document links from " .. client.name) | |
local params = { | |
textDocument = require("vim.lsp.util").make_text_document_params(bufnr), | |
} | |
client.request("textDocument/documentLink", params, nil, bufnr) | |
else | |
self._logger:trace(client.name .. " does not support document links") | |
end | |
end | |
if providers_found == 0 then | |
self._logger:debug("No document link providers found among attached clients") | |
else | |
self._logger:debug("Requested document links from " .. providers_found .. " providers") | |
end | |
end | |
---Setup document link handler with LSP | |
function DocumentLinkHandler:setup() | |
if not self._config.enabled then | |
self._logger:info("Document link handler is disabled") | |
return | |
end | |
self._logger:info("Setting up document link handler") | |
vim.lsp.handlers["textDocument/documentLink"] = vim.lsp.with( | |
function(err, result, ctx, config) | |
self:handle_document_links(err, result, ctx, config) | |
end, | |
{ | |
resolve_provider = self._config.resolve_links, | |
} | |
) | |
if self._config.auto_request and #self._config.events > 0 then | |
local timer = nil | |
local debounced_request = function() | |
if timer then | |
timer:stop() | |
timer:close() | |
end | |
timer = vim.loop.new_timer() | |
timer:start(self._config.debounce_ms, 0, vim.schedule_wrap(function() | |
self:request_document_links() | |
timer:close() | |
timer = nil | |
end)) | |
end | |
vim.api.nvim_create_autocmd(self._config.events, { | |
group = self._augroup_id, | |
callback = function(args) | |
local bufnr = args.buf | |
self._logger:trace("Auto-requesting document links for buffer " .. bufnr .. " on event " .. args.event) | |
debounced_request() | |
end, | |
desc = "Request document links automatically", | |
}) | |
self._logger:info("Enabled automatic document link requests on events: " .. table.concat(self._config.events, ", ")) | |
end | |
vim.api.nvim_create_autocmd("BufDelete", { | |
group = self._augroup_id, | |
callback = function(args) | |
local bufnr = args.buf | |
if self._document_links[bufnr] then | |
self._document_links[bufnr] = nil | |
self._logger:trace("Cleaned up document links for deleted buffer " .. bufnr) | |
end | |
end, | |
desc = "Clean up document link data when buffer is deleted", | |
}) | |
self._logger:info("Document link handler setup complete") | |
end | |
---Get document links for a buffer | |
---@param bufnr number|nil Buffer number (defaults to current buffer) | |
---@return table<string, DocumentLinkData> links Map of link keys to link data | |
function DocumentLinkHandler:get_links(bufnr) | |
bufnr = bufnr or vim.api.nvim_get_current_buf() | |
return self._document_links[bufnr] or {} | |
end | |
function DocumentLinkHandler:disable() | |
self._config.enabled = false | |
vim.api.nvim_del_augroup_by_id(self._augroup_id) | |
for bufnr, _ in pairs(self._document_links) do | |
if vim.api.nvim_buf_is_valid(bufnr) then | |
vim.api.nvim_buf_clear_namespace(bufnr, self._namespace_id, 0, -1) | |
end | |
end | |
self._document_links = {} | |
self._logger:info("Document link handler disabled") | |
end | |
---Module interface | |
local M = {} | |
---@type DocumentLinkHandler|nil | |
local handler_instance = nil | |
---Setup the document link handler | |
---@param config DocumentLinkConfig|nil Configuration options | |
function M.setup(config) | |
if handler_instance then | |
handler_instance:disable() | |
end | |
handler_instance = DocumentLinkHandler:new(config) | |
handler_instance:setup() | |
end | |
---Request document links manually | |
---@param bufnr number|nil Buffer number | |
function M.request(bufnr) | |
if not handler_instance then | |
vim.notify("[DocumentLink] Handler not initialized. Call setup() first.", vim.log.levels.ERROR) | |
return | |
end | |
handler_instance:request_document_links(bufnr) | |
end | |
---Get document links for a buffer | |
---@param bufnr number|nil Buffer number | |
---@return table<string, DocumentLinkData> links Map of link keys to link data | |
function M.get_links(bufnr) | |
if not handler_instance then | |
return {} | |
end | |
return handler_instance:get_links(bufnr) | |
end | |
---Disable the handler | |
function M.disable() | |
if handler_instance then | |
handler_instance:disable() | |
handler_instance = nil | |
end | |
end | |
return M |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment