From da30b1441952c859e152ff3c0a0b5af262891cba Mon Sep 17 00:00:00 2001 From: "Paul B. Kim" Date: Fri, 6 Mar 2026 11:15:24 +0900 Subject: [PATCH] feat(yank): add location reference clipboard utilities --- lua/custom/location_reference.lua | 244 ++++++++++++++++++++++ lua/custom/plugins/location_reference.lua | 41 ++++ 2 files changed, 285 insertions(+) create mode 100644 lua/custom/location_reference.lua create mode 100644 lua/custom/plugins/location_reference.lua diff --git a/lua/custom/location_reference.lua b/lua/custom/location_reference.lua new file mode 100644 index 00000000..3908bda2 --- /dev/null +++ b/lua/custom/location_reference.lua @@ -0,0 +1,244 @@ +local M = {} + +local sk = vim.lsp.protocol.SymbolKind +local symbol_kinds = { + [sk.Class] = true, + [sk.Method] = true, + [sk.Constructor] = true, + [sk.Enum] = true, + [sk.Interface] = true, + [sk.Function] = true, + [sk.Struct] = true, +} + +local function range_contains(range, line0, col0) + if not range then + return false + end + + local start_line = range.start.line + local start_col = range.start.character + local end_line = range['end'].line + local end_col = range['end'].character + + if line0 < start_line or line0 > end_line then + return false + end + if line0 == start_line and col0 < start_col then + return false + end + if line0 == end_line and col0 > end_col then + return false + end + + return true +end + +local function range_size(range) + -- Weight lines heavily so a single-line range is always smaller than a multi-line one + return (range['end'].line - range.start.line) * 100000 + (range['end'].character - range.start.character) +end + +local function append_document_symbols(symbols, out, parent_name) + for _, symbol in ipairs(symbols or {}) do + local symbol_name = symbol.name + local full_name = parent_name and (parent_name .. '.' .. symbol_name) or symbol_name + table.insert(out, { + name = full_name, + kind = symbol.kind, + range = symbol.range, + }) + append_document_symbols(symbol.children, out, full_name) + end +end + +local function append_symbol_information(symbols, out) + for _, symbol in ipairs(symbols or {}) do + local name = symbol.name + if symbol.containerName and symbol.containerName ~= '' then + name = symbol.containerName .. '.' .. name + end + table.insert(out, { + name = name, + kind = symbol.kind, + range = symbol.location and symbol.location.range, + }) + end +end + +local function get_symbol_at_cursor() + if #vim.lsp.get_clients { bufnr = 0 } == 0 then + return nil + end + + local params = { textDocument = vim.lsp.util.make_text_document_params() } + local responses = vim.lsp.buf_request_sync(0, 'textDocument/documentSymbol', params, 500) + if not responses then + return nil + end + + local cursor = vim.api.nvim_win_get_cursor(0) + local line0 = cursor[1] - 1 + -- LSP ranges use UTF-16 offsets; convert from byte offset for correct matching + -- on files with non-ASCII characters (e.g. CJK, emoji). + local line_text = vim.api.nvim_buf_get_lines(0, line0, line0 + 1, false)[1] or '' + local col0 = vim.str_utfindex(line_text, 'utf-16', cursor[2]) + local symbols = {} + + for _, response in pairs(responses) do + if not response.error then + local result = response.result + if type(result) == 'table' and result[1] then + if result[1].selectionRange or result[1].children then + append_document_symbols(result, symbols, nil) + else + append_symbol_information(result, symbols) + end + end + end + end + + local best_symbol, best_size + for _, symbol in ipairs(symbols) do + if symbol_kinds[symbol.kind] and range_contains(symbol.range, line0, col0) then + local size = range_size(symbol.range) + if not best_size or size < best_size then + best_size = size + best_symbol = symbol.name + end + end + end + + return best_symbol +end + +local function get_relative_path_from_root(file_path) + local root = vim.fs.root(file_path, { '.git' }) or vim.fn.getcwd() + + if vim.fs.relpath then + local rel = vim.fs.relpath(root, file_path) + if rel then + return rel + end + end + + local prefix = root + if not prefix:match '/$' then + prefix = prefix .. '/' + end + if file_path:sub(1, #prefix) == prefix then + return file_path:sub(#prefix + 1) + end + + return file_path +end + +local function get_file_parts() + local file_path = vim.api.nvim_buf_get_name(0) + if file_path == '' then + vim.notify('No file path for current buffer', vim.log.levels.WARN) + return nil + end + + return { + file_path = file_path, + file_rel = get_relative_path_from_root(file_path), + } +end + +local function get_location_parts() + local parts = get_file_parts() + if not parts then + return nil + end + + local mode = vim.api.nvim_get_mode().mode + local loc + + if mode == 'v' or mode == 'V' or mode == '\22' then + -- Use 'v' (visual anchor) and '.' (cursor) for the live selection. + -- ' marks are only reliable after exiting visual mode (e.g. ':' mappings), + -- but mappings stay in visual mode so those marks are stale. + local start_pos = vim.fn.getpos 'v' + local end_pos = vim.fn.getpos '.' + local s_line, s_col = start_pos[2], start_pos[3] + local e_line, e_col = end_pos[2], end_pos[3] + + -- getpos('v') returns zeros when called outside a real visual context; fall back. + if s_line == 0 then + local cur = vim.api.nvim_win_get_cursor(0) + s_line, s_col = cur[1], cur[2] + 1 + e_line, e_col = s_line, s_col + elseif s_line > e_line or (s_line == e_line and s_col > e_col) then + s_line, e_line = e_line, s_line + s_col, e_col = e_col, s_col + end + + loc = string.format('%d:%d-%d:%d', s_line, s_col, e_line, e_col) + else + local cursor = vim.api.nvim_win_get_cursor(0) + local line_num = cursor[1] + local line_text = vim.api.nvim_buf_get_lines(0, line_num - 1, line_num, false)[1] or '' + local end_col = math.max(vim.fn.strchars(line_text), 1) + loc = string.format('%d:%d-%d:%d', line_num, 1, line_num, end_col) + end + + return { + file_path = parts.file_path, + file_rel = parts.file_rel, + loc = loc, + symbol = get_symbol_at_cursor(), + } +end + +local function yank(label, value) + vim.fn.setreg('"', value) + pcall(vim.fn.setreg, '+', value) + vim.notify('Copied ' .. label .. ': ' .. value, vim.log.levels.INFO) +end + +function M.copy_absolute_location_reference() + local parts = get_location_parts() + if not parts then + return + end + + local location = string.format('%s:%s', parts.file_path, parts.loc) + if parts.symbol and parts.symbol ~= '' then + location = location .. ' symbol:' .. parts.symbol + end + + yank('absolute location', location) +end + +function M.copy_relative_location_reference() + local parts = get_location_parts() + if not parts then + return + end + + local symbol = parts.symbol and parts.symbol ~= '' and parts.symbol or 'none' + yank('relative location', string.format('@%s loc:%s symbol:%s', parts.file_rel, parts.loc, symbol)) +end + +function M.copy_buffer_file_reference() + local parts = get_file_parts() + if not parts then + return + end + + yank('buffer file', string.format('@%s', parts.file_rel)) +end + +function M.copy_buffer_directory_reference() + local parts = get_file_parts() + if not parts then + return + end + + local abs_dir = vim.fn.fnamemodify(parts.file_path, ':h') + local rel_dir = get_relative_path_from_root(abs_dir) + yank('buffer directory', string.format('@%s', rel_dir)) +end + +return M diff --git a/lua/custom/plugins/location_reference.lua b/lua/custom/plugins/location_reference.lua new file mode 100644 index 00000000..7cdee361 --- /dev/null +++ b/lua/custom/plugins/location_reference.lua @@ -0,0 +1,41 @@ +return { + 'folke/which-key.nvim', + keys = { + { + 'ya', + function() + require('custom.location_reference').copy_absolute_location_reference() + end, + mode = { 'n', 'x' }, + desc = '[Y]ank [A]bsolute ref', + }, + { + 'yr', + function() + require('custom.location_reference').copy_relative_location_reference() + end, + mode = { 'n', 'x' }, + desc = '[Y]ank [R]elative ref', + }, + { + 'yf', + function() + require('custom.location_reference').copy_buffer_file_reference() + end, + mode = { 'n', 'x' }, + desc = '[Y]ank Buffer [F]ile', + }, + { + 'yd', + function() + require('custom.location_reference').copy_buffer_directory_reference() + end, + mode = { 'n', 'x' }, + desc = '[Y]ank Buffer [D]irectory', + }, + }, + opts = function(_, opts) + opts.spec = opts.spec or {} + table.insert(opts.spec, { 'y', group = '[Y]ank' }) + end, +}