Skip to content

Commit c29cc16

Browse files
committed
feat: v2 - direct msgpack-rpc bridge, drop neocrush daemon
taigrr:crush/feature talks to Neovim directly via $NVIM instead of going through a workspace-keyed LSP/MCP daemon. The plugin now exposes a single neocrush.bridge module with four functions Crush calls over nvim_exec_lua: context, show_locations, flash_edit, file_changed. file_changed sets autoread + checktime when the buffer is unmodified, silencing the W11 "file changed since you opened it" prompt without clobbering unsaved user edits. The lsp.lua module is reduced to a deprecation shim: start_lsp() and get_client() are no-ops that warn once. Existing user configs keep working. Drops the registration of the standalone neocrush binary. BREAKING: requires taigrr:crush/feature. Users on upstream Crush should pin to the v1.x.x tag of this plugin, as this neocrush version will only work on the taigrr fork.
1 parent b38fc16 commit c29cc16

5 files changed

Lines changed: 344 additions & 237 deletions

File tree

lua/neocrush/bridge.lua

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
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

lua/neocrush/init.lua

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,12 +39,11 @@
3939

4040
local M = {}
4141

42-
-- Register binaries with glaze.nvim if available
42+
-- Register binaries with glaze.nvim if available. Note: v2 of
43+
-- neocrush.nvim no longer ships a separate `neocrush` daemon binary;
44+
-- the only Go program you need is `crush` itself.
4345
local _glaze_ok, _glaze = pcall(require, 'glaze')
4446
if _glaze_ok then
45-
_glaze.register('neocrush', 'github.com/taigrr/neocrush/cmd/neocrush', {
46-
plugin = 'neocrush.nvim',
47-
})
4847
_glaze.register('crush', 'github.com/charmbracelet/crush', {
4948
plugin = 'neocrush.nvim',
5049
})

0 commit comments

Comments
 (0)