|
| 1 | +---@brief [[ |
| 2 | +--- neocrush.bridge - Direct RPC bridge between Crush (the binary) and this Neovim instance. |
| 3 | +--- |
| 4 | +--- This module is the entire wire protocol Crush v2 uses. Crush dials the |
| 5 | +--- $NVIM socket of the Neovim instance that spawned it (via :terminal), |
| 6 | +--- then calls these functions over msgpack-rpc using nvim_exec_lua. No |
| 7 | +--- LSP, no daemon, no socket discovery: the (nvim, crush) pair is |
| 8 | +--- pinned by the OS. |
| 9 | +--- |
| 10 | +--- Functions exposed: |
| 11 | +--- context() -> table with cursor / surrounding lines / selection. |
| 12 | +--- show_locations(t, items) -> opens the Telescope picker (or quickfix). |
| 13 | +--- flash_edit(p, s, e) -> highlights a freshly-edited region. |
| 14 | +--- file_changed(p) -> reloads a buffer Crush wrote on disk; kills the W11 prompt. |
| 15 | +---@brief ]] |
| 16 | + |
| 17 | +local M = {} |
| 18 | + |
| 19 | +local CONTEXT_RADIUS = 5 |
| 20 | + |
| 21 | +--------------------------------------------------------------------------- |
| 22 | +-- helpers |
| 23 | +--------------------------------------------------------------------------- |
| 24 | + |
| 25 | +---Find the buffer holding the given absolute path, if any. Returns 0 when |
| 26 | +---no buffer matches; callers treat 0 as "buffer not loaded". |
| 27 | +---@param path string |
| 28 | +---@return integer bufnr 0 when no match |
| 29 | +local function find_buffer(path) |
| 30 | + if path == nil or path == '' then |
| 31 | + return 0 |
| 32 | + end |
| 33 | + local target = vim.fn.fnamemodify(path, ':p') |
| 34 | + for _, buf in ipairs(vim.api.nvim_list_bufs()) do |
| 35 | + if vim.api.nvim_buf_is_loaded(buf) then |
| 36 | + local name = vim.api.nvim_buf_get_name(buf) |
| 37 | + if name ~= '' and vim.fn.fnamemodify(name, ':p') == target then |
| 38 | + return buf |
| 39 | + end |
| 40 | + end |
| 41 | + end |
| 42 | + return 0 |
| 43 | +end |
| 44 | + |
| 45 | +---Return up to `radius` lines before and after the given 0-indexed line |
| 46 | +---in `bufnr`. Always returns three strings (before / line / after) so |
| 47 | +---callers can pass them straight through to JSON. |
| 48 | +---@param bufnr integer |
| 49 | +---@param line integer 0-indexed |
| 50 | +---@param radius integer |
| 51 | +---@return string before |
| 52 | +---@return string current |
| 53 | +---@return string after |
| 54 | +local function surrounding(bufnr, line, radius) |
| 55 | + local total = vim.api.nvim_buf_line_count(bufnr) |
| 56 | + local before_start = math.max(0, line - radius) |
| 57 | + local after_end = math.min(total, line + radius + 1) |
| 58 | + |
| 59 | + local before = vim.api.nvim_buf_get_lines(bufnr, before_start, line, false) |
| 60 | + local current = vim.api.nvim_buf_get_lines(bufnr, line, line + 1, false) |
| 61 | + local after = vim.api.nvim_buf_get_lines(bufnr, line + 1, after_end, false) |
| 62 | + |
| 63 | + return table.concat(before, '\n'), table.concat(current, '\n'), table.concat(after, '\n') |
| 64 | +end |
| 65 | + |
| 66 | +---Read the current visual selection. Empty string when nothing is |
| 67 | +---selected. Uses the '< / '> marks so it works after the user has left |
| 68 | +---visual mode. |
| 69 | +---@param bufnr integer |
| 70 | +---@return boolean has_selection |
| 71 | +---@return string text |
| 72 | +local function visual_selection(bufnr) |
| 73 | + local s = vim.api.nvim_buf_get_mark(bufnr, '<') |
| 74 | + local e = vim.api.nvim_buf_get_mark(bufnr, '>') |
| 75 | + if s[1] == 0 or e[1] == 0 then |
| 76 | + return false, '' |
| 77 | + end |
| 78 | + if s[1] == e[1] and s[2] == e[2] then |
| 79 | + return false, '' |
| 80 | + end |
| 81 | + -- nvim_buf_get_text uses 0-indexed rows but inclusive columns. |
| 82 | + local ok, lines = pcall(vim.api.nvim_buf_get_text, bufnr, s[1] - 1, s[2], e[1] - 1, e[2] + 1, {}) |
| 83 | + if not ok or not lines or #lines == 0 then |
| 84 | + return false, '' |
| 85 | + end |
| 86 | + local text = table.concat(lines, '\n') |
| 87 | + return text ~= '', text |
| 88 | +end |
| 89 | + |
| 90 | +--------------------------------------------------------------------------- |
| 91 | +-- API: context() |
| 92 | +--------------------------------------------------------------------------- |
| 93 | + |
| 94 | +---Snapshot the current editor state. Pull-based on every call, so the |
| 95 | +---result always reflects the cursor position at the moment Crush asked. |
| 96 | +---@return table |
| 97 | +function M.context() |
| 98 | + local win = vim.api.nvim_get_current_win() |
| 99 | + local buf = vim.api.nvim_win_get_buf(win) |
| 100 | + local cursor = vim.api.nvim_win_get_cursor(win) |
| 101 | + local row, col = cursor[1] - 1, cursor[2] |
| 102 | + |
| 103 | + local path = vim.api.nvim_buf_get_name(buf) |
| 104 | + if path ~= '' then |
| 105 | + path = vim.fn.fnamemodify(path, ':p') |
| 106 | + end |
| 107 | + |
| 108 | + local before, current, after = surrounding(buf, row, CONTEXT_RADIUS) |
| 109 | + local has_sel, sel = visual_selection(buf) |
| 110 | + |
| 111 | + return { |
| 112 | + path = path, |
| 113 | + uri = path ~= '' and ('file://' .. path) or '', |
| 114 | + line = row, |
| 115 | + column = col, |
| 116 | + context_before = before, |
| 117 | + context_line = current, |
| 118 | + context_after = after, |
| 119 | + total_lines = vim.api.nvim_buf_line_count(buf), |
| 120 | + has_selection = has_sel, |
| 121 | + selection = sel, |
| 122 | + } |
| 123 | +end |
| 124 | + |
| 125 | +--------------------------------------------------------------------------- |
| 126 | +-- API: show_locations(title, items) |
| 127 | +--------------------------------------------------------------------------- |
| 128 | + |
| 129 | +---Forward the AI's annotated locations to the Telescope picker (with |
| 130 | +---fallback to quickfix). Delegates to the existing locations module so |
| 131 | +---the picker UI is unchanged from v1. |
| 132 | +---@param title string|nil |
| 133 | +---@param items table |
| 134 | +function M.show_locations(title, items) |
| 135 | + local ok, locations = pcall(require, 'neocrush.locations') |
| 136 | + if not ok then |
| 137 | + vim.notify('neocrush.locations module missing: ' .. tostring(locations), vim.log.levels.ERROR) |
| 138 | + return |
| 139 | + end |
| 140 | + -- Schedule because Crush may call us from the RPC thread. |
| 141 | + vim.schedule(function() |
| 142 | + locations.handler(nil, { title = title, items = items or {} }, nil, nil) |
| 143 | + end) |
| 144 | +end |
| 145 | + |
| 146 | +--------------------------------------------------------------------------- |
| 147 | +-- API: flash_edit(path, start_line, end_line) |
| 148 | +--------------------------------------------------------------------------- |
| 149 | + |
| 150 | +---Briefly highlight the region [start_line, end_line) in the buffer for |
| 151 | +---path. Does nothing if the file isn't open in this Neovim. Best-effort: |
| 152 | +---never raises so a transient API change can't cascade into a Crush tool |
| 153 | +---failure. |
| 154 | +---@param path string absolute file path |
| 155 | +---@param start_line integer 0-indexed |
| 156 | +---@param end_line integer 0-indexed exclusive |
| 157 | +function M.flash_edit(path, start_line, end_line) |
| 158 | + vim.schedule(function() |
| 159 | + local highlight = require 'neocrush.highlight' |
| 160 | + local bufnr = find_buffer(path) |
| 161 | + if bufnr == 0 then |
| 162 | + return |
| 163 | + end |
| 164 | + -- Keep buffer in sync with disk before we paint extmarks; otherwise |
| 165 | + -- the line range may already be stale. |
| 166 | + vim.api.nvim_buf_call(bufnr, function() |
| 167 | + pcall(vim.cmd, 'silent! checktime') |
| 168 | + end) |
| 169 | + pcall(highlight.flash_range, bufnr, start_line, end_line) |
| 170 | + end) |
| 171 | +end |
| 172 | + |
| 173 | +--------------------------------------------------------------------------- |
| 174 | +-- API: file_changed(path) |
| 175 | +--------------------------------------------------------------------------- |
| 176 | + |
| 177 | +---Tell Neovim that path was just written by Crush. If the buffer is |
| 178 | +---loaded and unmodified, reload silently (suppressing the W11 "file has |
| 179 | +---been edited since you opened" prompt). If the buffer has unsaved user |
| 180 | +---edits, leave it alone — clobbering the user's work would be worse than |
| 181 | +---a prompt. |
| 182 | +---@param path string absolute file path |
| 183 | +function M.file_changed(path) |
| 184 | + vim.schedule(function() |
| 185 | + local bufnr = find_buffer(path) |
| 186 | + if bufnr == 0 then |
| 187 | + return |
| 188 | + end |
| 189 | + if vim.bo[bufnr].modified then |
| 190 | + -- User has unsaved edits; do not touch. |
| 191 | + return |
| 192 | + end |
| 193 | + -- autoread + checktime is the standard incantation: it reloads the |
| 194 | + -- buffer from disk if the on-disk mtime is newer, no prompt. |
| 195 | + vim.bo[bufnr].autoread = true |
| 196 | + vim.api.nvim_buf_call(bufnr, function() |
| 197 | + pcall(vim.cmd, 'silent! checktime') |
| 198 | + end) |
| 199 | + end) |
| 200 | +end |
| 201 | + |
| 202 | +return M |
0 commit comments