From cbfd797e56f204c0f9564c216bc78c1f10e39c8b Mon Sep 17 00:00:00 2001 From: "Paul B. Kim" Date: Fri, 6 Mar 2026 12:27:42 +0900 Subject: [PATCH] refactor(config): inline location reference plugin and refresh docs --- AGENTS.md | 2 +- README.md | 22 +- lua/custom/health.lua | 52 ----- lua/custom/location_reference.lua | 244 -------------------- lua/custom/plugins/location_reference.lua | 257 +++++++++++++++++++++- 5 files changed, 263 insertions(+), 314 deletions(-) delete mode 100644 lua/custom/health.lua delete mode 100644 lua/custom/location_reference.lua diff --git a/AGENTS.md b/AGENTS.md index 73e1b28f..f329f55b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,7 @@ - Root entrypoint: `init.lua` (Kickstart-based primary config). - Custom Lua modules live under `lua/custom/`. - Plugin specs are split into `lua/custom/plugins/*.lua` (one concern per file, e.g. `gitsigns.lua`, `lint.lua`, `persistence.lua`). -- Utility modules (non-plugin config) live in `lua/custom/*.lua` (for example `wrapping.lua`, `health.lua`). +- Utility modules (non-plugin config) live in `lua/custom/*.lua` (for example `wrapping.lua`). - Reference docs live in `README.md` and `doc/kickstart.txt`. - Plugin versions are pinned in `lazy-lock.json`. diff --git a/README.md b/README.md index cb53f9c9..fa13a5af 100644 --- a/README.md +++ b/README.md @@ -44,15 +44,20 @@ nvim --headless "+MasonToolsInstallSync" "+qa" ### Git workflow -- `gg`: open Neogit UI -- `gd`: open Diffview -- `gD`: close Diffview -- `gf`: Diffview file history (current file) -- `gF`: Diffview repo history +- `Gg`: open Neogit UI +- `Gd`: open Diffview +- `GD`: close Diffview +- `Gf`: Diffview file history (current file) +- `GF`: Diffview repo history - `h...`: Gitsigns hunk actions (`:which-key h`) ### Diagnostics and code navigation +- `gd`: goto definition +- `gD`: goto declaration +- `gr`: goto references +- `gi`: goto implementation +- `gt`: goto type definition - `xx`: Trouble diagnostics - `xw`: Trouble workspace diagnostics - `xd`: Trouble current buffer diagnostics @@ -64,6 +69,13 @@ nvim --headless "+MasonToolsInstallSync" "+qa" - `jc` / `jC`: next/previous class start - Textobject select (operator-pending/visual): `af`/`if` for function, `ac`/`ic` for class +### Yank references (AI-friendly) + +- `ya`: yank absolute reference with location and symbol +- `yr`: yank relative reference with location and symbol +- `yf`: yank current buffer file as `@relative/path` +- `yd`: yank current buffer parent directory as `@relative/dir` + ### Tests and debug - `nr`: neotest run nearest diff --git a/lua/custom/health.lua b/lua/custom/health.lua deleted file mode 100644 index b59d0864..00000000 --- a/lua/custom/health.lua +++ /dev/null @@ -1,52 +0,0 @@ ---[[ --- --- This file is not required for your own configuration, --- but helps people determine if their system is setup correctly. --- ---]] - -local check_version = function() - local verstr = tostring(vim.version()) - if not vim.version.ge then - vim.health.error(string.format("Neovim out of date: '%s'. Upgrade to latest stable or nightly", verstr)) - return - end - - if vim.version.ge(vim.version(), '0.10-dev') then - vim.health.ok(string.format("Neovim version is: '%s'", verstr)) - else - vim.health.error(string.format("Neovim out of date: '%s'. Upgrade to latest stable or nightly", verstr)) - end -end - -local check_external_reqs = function() - -- Basic utils: `git`, `make`, `unzip` - for _, exe in ipairs { 'git', 'make', 'unzip', 'rg' } do - local is_executable = vim.fn.executable(exe) == 1 - if is_executable then - vim.health.ok(string.format("Found executable: '%s'", exe)) - else - vim.health.warn(string.format("Could not find executable: '%s'", exe)) - end - end - - return true -end - -return { - check = function() - vim.health.start 'kickstart.nvim' - - vim.health.info [[NOTE: Not every warning is a 'must-fix' in `:checkhealth` - - Fix only warnings for plugins and languages you intend to use. - Mason will give warnings for languages that are not installed. - You do not need to install, unless you want to use those languages!]] - - local uv = vim.uv or vim.loop - vim.health.info('System Information: ' .. vim.inspect(uv.os_uname())) - - check_version() - check_external_reqs() - end, -} diff --git a/lua/custom/location_reference.lua b/lua/custom/location_reference.lua deleted file mode 100644 index 3908bda2..00000000 --- a/lua/custom/location_reference.lua +++ /dev/null @@ -1,244 +0,0 @@ -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 index 7cdee361..1b447251 100644 --- a/lua/custom/plugins/location_reference.lua +++ b/lua/custom/plugins/location_reference.lua @@ -1,35 +1,268 @@ +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 + +local function 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 + +local function 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 + +local function 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 + +local function 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 { 'folke/which-key.nvim', keys = { { 'ya', - function() - require('custom.location_reference').copy_absolute_location_reference() - end, + copy_absolute_location_reference, mode = { 'n', 'x' }, desc = '[Y]ank [A]bsolute ref', }, { 'yr', - function() - require('custom.location_reference').copy_relative_location_reference() - end, + copy_relative_location_reference, mode = { 'n', 'x' }, desc = '[Y]ank [R]elative ref', }, { 'yf', - function() - require('custom.location_reference').copy_buffer_file_reference() - end, + copy_buffer_file_reference, mode = { 'n', 'x' }, desc = '[Y]ank Buffer [F]ile', }, { 'yd', - function() - require('custom.location_reference').copy_buffer_directory_reference() - end, + copy_buffer_directory_reference, mode = { 'n', 'x' }, desc = '[Y]ank Buffer [D]irectory', },