| 1 |
" mcp-vim.vim - Vim client for the mcp-vim broker (protocol version 1.0). |
| 2 |
" |
| 3 |
" The broker exposes live Vim buffers to MCP clients and also supports |
| 4 |
" browser-initiated editing sessions. Vim itself uses a private protocol |
| 5 |
" to talk to the broker; it is not an MCP client. |
| 6 |
" |
| 7 |
" Installation: |
| 8 |
" In the Perl source distribution: vim/plugin/mcp-vim.vim |
| 9 |
" Published URL: https://dist.schmorp.de/mcp-vim/1/mcp-vim.vim |
| 10 |
" Copy the file to ~/.vim/plugin/mcp-vim.vim to load it at startup, |
| 11 |
" or load it manually with :source /path/to/mcp-vim.vim. |
| 12 |
" |
| 13 |
" Usage: |
| 14 |
" :Mcp http://HOST:PORT/COOKIE/TARGET |
| 15 |
" |
| 16 |
" The broker supplies the connection URL, including its authentication |
| 17 |
" cookie and the target name. To avoid typing the common prefix, set |
| 18 |
" this in your vimrc: |
| 19 |
" |
| 20 |
" let g:mcp_broker_url = 'http://HOST:PORT/COOKIE/' |
| 21 |
" |
| 22 |
" Then connect with :Mcp TARGET. Disconnected sessions are retried. |
| 23 |
" McpHasTarget() reports whether a target URL has been configured. |
| 24 |
" |
| 25 |
" The /1/ in the distribution URL denotes the private Vim/broker |
| 26 |
" protocol major version, not the MCP protocol version. |
| 27 |
" |
| 28 |
" Restart Vim after updating this file; re-sourcing does not reload it. |
| 29 |
|
| 30 |
if exists ('g:loaded_mcp_vim') |
| 31 |
finish |
| 32 |
endif |
| 33 |
let g:loaded_mcp_vim = 1 |
| 34 |
|
| 35 |
let s:prot_major = 1 |
| 36 |
let s:prot_minor = 0 |
| 37 |
|
| 38 |
let s:instance_id = hostname () . ':' . getpid () . ':' . localtime () |
| 39 |
if has ('reltime') |
| 40 |
let s:instance_id .= ':' . reltimestr (reltime ()) |
| 41 |
endif |
| 42 |
|
| 43 |
let s:channel = v:null |
| 44 |
let s:sessions = {} |
| 45 |
let s:popup = 0 |
| 46 |
let s:url = '' |
| 47 |
let s:target = '' |
| 48 |
let s:retry_timer = -1 |
| 49 |
let s:incoming = v:null |
| 50 |
|
| 51 |
command! -nargs=1 Xpcse call s:connect (<f-args>) |
| 52 |
command! -nargs=1 Mcp call s:connect (<f-args>) |
| 53 |
|
| 54 |
function! McpHasTarget () abort |
| 55 |
return !empty (s:url) |
| 56 |
endfunction |
| 57 |
|
| 58 |
function! s:stop_retry () abort |
| 59 |
if s:retry_timer != -1 |
| 60 |
call timer_stop (s:retry_timer) |
| 61 |
let s:retry_timer = -1 |
| 62 |
endif |
| 63 |
endfunction |
| 64 |
|
| 65 |
function! s:retry (timer) abort |
| 66 |
if type (s:channel) != v:t_channel |
| 67 |
echom 'mcp-vim: reconnecting to ' . s:target |
| 68 |
call s:connect (s:url) |
| 69 |
endif |
| 70 |
endfunction |
| 71 |
|
| 72 |
function! s:quit () abort |
| 73 |
if type (s:channel) == v:t_channel |
| 74 |
if ch_status (s:channel) ==# 'open' |
| 75 |
call ch_sendexpr (s:channel, ['quit']) |
| 76 |
endif |
| 77 |
call ch_close (s:channel) |
| 78 |
let s:channel = v:null |
| 79 |
endif |
| 80 |
endfunction |
| 81 |
|
| 82 |
function! s:connect (url) abort |
| 83 |
let url = a:url |
| 84 |
|
| 85 |
if url !~# '^http://' |
| 86 |
let url = get (g:, 'mcp_broker_url', '') . url |
| 87 |
endif |
| 88 |
|
| 89 |
let s:url = url |
| 90 |
|
| 91 |
let m = matchlist (url, '^http://\([^:/]*\):\([0-9]\+\)/\([^/]\+\)/\(.*\)$') |
| 92 |
|
| 93 |
if empty (m) |
| 94 |
echoerr "Bad mcp-vim broker URL" |
| 95 |
return |
| 96 |
endif |
| 97 |
|
| 98 |
call s:quit () |
| 99 |
|
| 100 |
let host = m[1] |
| 101 |
let port = m[2] |
| 102 |
let cookie = m[3] |
| 103 |
let id = m[4] |
| 104 |
let s:target = id |
| 105 |
|
| 106 |
let s:channel = ch_open (host . ':' . port, { |
| 107 |
\ 'callback': function ('s:receive'), |
| 108 |
\ 'close_cb': function ('s:disconnected'), |
| 109 |
\ }) |
| 110 |
|
| 111 |
if ch_status (s:channel) ==# 'open' |
| 112 |
call s:stop_retry () |
| 113 |
|
| 114 |
call ch_sendexpr (s:channel, ['mcp-vim-connect', s:prot_major, s:prot_minor, cookie, id, s:instance_id]) |
| 115 |
|
| 116 |
for session in values (s:sessions) |
| 117 |
call ch_sendexpr (s:channel, ['session', session.session, session.state]) |
| 118 |
endfor |
| 119 |
|
| 120 |
call ch_sendexpr (s:channel, ['sessions-done']) |
| 121 |
else |
| 122 |
let s:channel = v:null |
| 123 |
echom 'mcp-vim: connection failed' |
| 124 |
endif |
| 125 |
endfunction |
| 126 |
|
| 127 |
function! s:disconnected (channel) abort |
| 128 |
echom 'mcp-vim: disconnected from ' . s:target |
| 129 |
let s:channel = v:null |
| 130 |
|
| 131 |
if s:retry_timer == -1 |
| 132 |
let s:retry_timer = timer_start (1000, function ('s:retry'), { 'repeat': 60 }) |
| 133 |
endif |
| 134 |
endfunction |
| 135 |
|
| 136 |
""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""" |
| 137 |
" RPC support. |
| 138 |
" |
| 139 |
" broker -> vim: ['call', id, method, args...] |
| 140 |
" vim -> broker: ['reply', id, result] |
| 141 |
" vim -> broker: ['error', id, exception, throwpoint] |
| 142 |
" |
| 143 |
" Methods: |
| 144 |
" state () |
| 145 |
" read_lines (buffer, ranges) |
| 146 |
" grep (buffers, pattern, mode, max_results) |
| 147 |
" replace_lines (buffer, line, old_lines, new_lines, expected_changedtick) |
| 148 |
" apply_patch (buffer, patch, max_offsets, expected_changedtick) " requires ordinary unified diff; MCP wrapper may relax @@ headers |
| 149 |
" substitute (buffer, range, pattern, replacement, flags, expected_changedtick) |
| 150 |
" set_cursor (buffer, line, col [, expected_changedtick]) |
| 151 |
" |
| 152 |
" Explicit buffer ids are loaded on demand. In state, changedtick and line_count |
| 153 |
" are v:null until a buffer has been loaded. |
| 154 |
|
| 155 |
let s:rpc_sid = expand ('<SID>') |
| 156 |
|
| 157 |
function! s:rpc_bufinfo (buf) abort |
| 158 |
let info = getbufinfo (a:buf) |
| 159 |
|
| 160 |
if empty (info) |
| 161 |
throw 'rpc/no_such_buffer: ' . a:buf |
| 162 |
endif |
| 163 |
|
| 164 |
if !info[0].loaded |
| 165 |
call bufload (a:buf) |
| 166 |
let info = getbufinfo (a:buf) |
| 167 |
|
| 168 |
if empty (info) || !info[0].loaded |
| 169 |
throw 'rpc/cannot_load_buffer: ' . a:buf |
| 170 |
endif |
| 171 |
endif |
| 172 |
|
| 173 |
return info[0] |
| 174 |
endfunction |
| 175 |
|
| 176 |
function! s:rpc_expect_tick (expected) abort |
| 177 |
if b:changedtick != a:expected |
| 178 |
throw printf ('rpc/stale_buffer: expected %d, got %d', a:expected, b:changedtick) |
| 179 |
endif |
| 180 |
endfunction |
| 181 |
|
| 182 |
function! s:rpc_break_undo () abort |
| 183 |
let &g:undolevels = &g:undolevels |
| 184 |
endfunction |
| 185 |
|
| 186 |
" Execute with {buf} current, while disturbing the user's window/buffer/cursor state as little as possible. |
| 187 |
function! s:rpc_in_buffer (buf, fun, ...) abort |
| 188 |
let restoreview = !a:0 || a:1 |
| 189 |
|
| 190 |
let info = s:rpc_bufinfo (a:buf) |
| 191 |
let oldwin = win_getid () |
| 192 |
let oldbuf = bufnr ('%') |
| 193 |
let oldview = winsaveview () |
| 194 |
|
| 195 |
try |
| 196 |
if a:buf != oldbuf |
| 197 |
if !empty (info.windows) |
| 198 |
noautocmd call win_gotoid (info.windows[0]) |
| 199 |
|
| 200 |
if win_getid () != info.windows[0] |
| 201 |
throw 'rpc/cannot_enter_window: ' . info.windows[0] |
| 202 |
endif |
| 203 |
else |
| 204 |
execute 'noautocmd keepalt keepjumps hide buffer ' . a:buf |
| 205 |
endif |
| 206 |
endif |
| 207 |
|
| 208 |
let targetwin = win_getid () |
| 209 |
let targetview = winsaveview () |
| 210 |
|
| 211 |
try |
| 212 |
return call (a:fun, []) |
| 213 |
finally |
| 214 |
if restoreview |
| 215 |
\ && win_getid () == targetwin |
| 216 |
\ && bufnr ('%') == a:buf |
| 217 |
call winrestview (targetview) |
| 218 |
endif |
| 219 |
endtry |
| 220 |
finally |
| 221 |
noautocmd call win_gotoid (oldwin) |
| 222 |
|
| 223 |
if win_getid () != oldwin |
| 224 |
throw 'rpc/cannot_restore_window: ' . oldwin |
| 225 |
endif |
| 226 |
|
| 227 |
if bufnr ('%') != oldbuf |
| 228 |
execute 'noautocmd keepalt keepjumps hide buffer ' . oldbuf |
| 229 |
endif |
| 230 |
|
| 231 |
" Restore the caller's original view unless that is the buffer |
| 232 |
" we deliberately modified and want Vim's adjustment to survive. |
| 233 |
if restoreview || oldbuf != a:buf |
| 234 |
call winrestview (oldview) |
| 235 |
endif |
| 236 |
endtry |
| 237 |
endfunction |
| 238 |
|
| 239 |
" Validate a small Ex-range subset, then let Ex resolve it. |
| 240 |
" A range-function gets the resolved addresses in a:firstline/a:lastline. |
| 241 |
" |
| 242 |
" range := % | address ([,;] address)? |
| 243 |
" address := base offset* | offset+ |
| 244 |
" base := . | $ | [1-9][0-9]* | /regexp/ | ?regexp? |
| 245 |
" offset := [+-][0-9]* |
| 246 |
" regexp := one or more non-delimiter characters; backslash escapes |
| 247 |
" the following character, including the delimiter |
| 248 |
let s:rpc_range_result = v:null |
| 249 |
|
| 250 |
function! s:rpc_capture_range () range abort |
| 251 |
let s:rpc_range_result = [a:firstline, a:lastline] |
| 252 |
endfunction |
| 253 |
|
| 254 |
function! s:rpc_validate_range (range) abort |
| 255 |
if a:range =~# '[\r\n]' |
| 256 |
throw 'rpc/bad_range: ' . a:range |
| 257 |
endif |
| 258 |
|
| 259 |
let fwd_char = '\%([^/\\]\|\\.\)' |
| 260 |
let bwd_char = '\%([^?\\]\|\\.\)' |
| 261 |
let fwd = '/' . fwd_char . '\+/' |
| 262 |
let bwd = '?' . bwd_char . '\+?' |
| 263 |
let base = '\%([.$]\|[1-9][0-9]*\|' . fwd . '\|' . bwd . '\)' |
| 264 |
let offset = '[+-][0-9]*' |
| 265 |
let address = '\%(' . base . '\%(' . offset . '\)*\|\%(' . offset . '\)\+\)' |
| 266 |
let range = '\%(%\|' . address . '\%([,;]' . address . '\)\?\)' |
| 267 |
|
| 268 |
if a:range !~# '^' . range . '$' |
| 269 |
throw 'rpc/bad_or_unsupported_range: ' . a:range |
| 270 |
endif |
| 271 |
endfunction |
| 272 |
|
| 273 |
function! s:rpc_range (range, line_count) abort |
| 274 |
call s:rpc_validate_range (a:range) |
| 275 |
|
| 276 |
let range = a:range |
| 277 |
let start = matchlist (range, '^\([1-9][0-9]*\)\([,;]\)') |
| 278 |
let end = matchlist (range, '\([,;]\)\([1-9][0-9]*\)$') |
| 279 |
|
| 280 |
if !empty (start) && str2nr (start[1]) > a:line_count |
| 281 |
throw printf ('rpc/range_start_beyond_eof: %d > %d', str2nr (start[1]), a:line_count) |
| 282 |
endif |
| 283 |
|
| 284 |
if !empty (end) && str2nr (end[2]) > a:line_count |
| 285 |
let range = substitute (range, '\([,;]\)[1-9][0-9]*$', '\1' . a:line_count, '') |
| 286 |
elseif range =~# '^[1-9][0-9]*$' && str2nr (range) > a:line_count |
| 287 |
throw printf ('rpc/range_start_beyond_eof: %d > %d', str2nr (range), a:line_count) |
| 288 |
endif |
| 289 |
|
| 290 |
let pos = getpos ('.') |
| 291 |
let s:rpc_range_result = v:null |
| 292 |
|
| 293 |
try |
| 294 |
execute 'silent keepjumps keeppatterns ' . range . ' call ' . s:rpc_sid . 'rpc_capture_range ()' |
| 295 |
finally |
| 296 |
call setpos ('.', pos) |
| 297 |
endtry |
| 298 |
|
| 299 |
if type (s:rpc_range_result) != v:t_list |
| 300 |
throw 'rpc/bad_range: ' . a:range |
| 301 |
endif |
| 302 |
|
| 303 |
return s:rpc_range_result |
| 304 |
endfunction |
| 305 |
|
| 306 |
function! s:rpc_call_state () abort |
| 307 |
let buffers = [] |
| 308 |
|
| 309 |
for info in getbufinfo () |
| 310 |
call add (buffers, { |
| 311 |
\ 'buffer': info.bufnr, |
| 312 |
\ 'name': info.name, |
| 313 |
\ 'listed': info.listed ? v:true : v:false, |
| 314 |
\ 'modified': info.changed ? v:true : v:false, |
| 315 |
\ 'changedtick': info.loaded ? info.changedtick : v:null, |
| 316 |
\ 'line_count': info.loaded ? info.linecount : v:null, |
| 317 |
\ 'buftype': getbufvar (info.bufnr, '&buftype'), |
| 318 |
\ 'filetype': getbufvar (info.bufnr, '&filetype'), |
| 319 |
\ }) |
| 320 |
endfor |
| 321 |
|
| 322 |
return { |
| 323 |
\ 'cwd': getcwd (), |
| 324 |
\ 'current_buffer': bufnr ('%'), |
| 325 |
\ 'cursor': {'line': line ('.'), 'column': col ('.')}, |
| 326 |
\ 'buffers': buffers, |
| 327 |
\ } |
| 328 |
endfunction |
| 329 |
|
| 330 |
function! s:rpc_call_apply_patch (buf, patch, max_offsets, expected_changedtick) abort |
| 331 |
return s:rpc_in_buffer (a:buf, {-> s:rpc_apply_patch ( |
| 332 |
\ a:patch, |
| 333 |
\ a:max_offsets, |
| 334 |
\ a:expected_changedtick |
| 335 |
\ )}, v:false) |
| 336 |
endfunction |
| 337 |
|
| 338 |
function! s:rpc_apply_patch (patch, max_offsets, expected_changedtick) abort |
| 339 |
call s:rpc_expect_tick (a:expected_changedtick) |
| 340 |
|
| 341 |
if !&modifiable |
| 342 |
throw 'rpc/not_modifiable' |
| 343 |
endif |
| 344 |
|
| 345 |
let old_lines = getline (1, '$') |
| 346 |
let oldfile = tempname () |
| 347 |
let newfile = tempname () |
| 348 |
|
| 349 |
try |
| 350 |
" Patch complete logical lines, independently of the buffer's &endofline. |
| 351 |
" writefile(..., 'b') needs a final empty list item to write the last NL. |
| 352 |
if writefile (old_lines + [''], oldfile, 'b') |
| 353 |
throw 'rpc/patch_write_failed' |
| 354 |
endif |
| 355 |
|
| 356 |
" patch does all unified-diff parsing/matching. It never modifies the |
| 357 |
" Vim buffer. Keep rejects out of the filesystem. |
| 358 |
let cmd = 'LC_ALL=C patch --unified --batch --forward --get=0 --fuzz=0' |
| 359 |
\ . ' --reject-file=- --no-backup-if-mismatch' |
| 360 |
\ . ' -o ' . shellescape (newfile) |
| 361 |
\ . ' ' . shellescape (oldfile) |
| 362 |
\ . ' 2>&1' |
| 363 |
|
| 364 |
let output = system (cmd, a:patch) |
| 365 |
let status = v:shell_error |
| 366 |
|
| 367 |
if status |
| 368 |
throw printf ( |
| 369 |
\ 'rpc/patch_failed: exit=%d %s', |
| 370 |
\ status, |
| 371 |
\ substitute (trim (output), "\n", ' | ', 'g') |
| 372 |
\ ) |
| 373 |
endif |
| 374 |
|
| 375 |
" --fuzz=0 still permits relocating whole hunks. The MCP wrapper rejects |
| 376 |
" ambiguous pure insertions and limits relocation by distinct old-side lines. |
| 377 |
for line in split (output, "\n") |
| 378 |
let match = matchlist (line, '^Hunk #\(\d\+\).*\<offset\s\+\([+-]\=\d\+\)\s\+lines\?\>') |
| 379 |
|
| 380 |
if !empty (match) |
| 381 |
let hunk = str2nr (match[1]) |
| 382 |
let offset = str2nr (match[2]) |
| 383 |
|
| 384 |
if hunk < 1 || hunk > len (a:max_offsets) |
| 385 |
throw 'rpc/patch_bad_output: ' . substitute (trim (output), "\n", ' | ', 'g') |
| 386 |
endif |
| 387 |
|
| 388 |
if abs (offset) > a:max_offsets[hunk - 1] |
| 389 |
throw printf ('rpc/patch_needs_more_context: hunk=%d offset=%d max=%d %s', |
| 390 |
\ hunk, offset, a:max_offsets[hunk - 1], |
| 391 |
\ substitute (trim (output), "\n", ' | ', 'g')) |
| 392 |
endif |
| 393 |
endif |
| 394 |
endfor |
| 395 |
|
| 396 |
" Require complete lines in nonempty patch output. A diff containing |
| 397 |
" a new-side no-EOL marker can otherwise remove the final NL. |
| 398 |
" Empty output represents Vim's single empty logical line. |
| 399 |
let new_lines = readfile (newfile, 'b') |
| 400 |
|
| 401 |
if !empty (new_lines) |
| 402 |
if new_lines[-1] !=# '' |
| 403 |
throw 'rpc/patch_missing_eol' |
| 404 |
endif |
| 405 |
call remove (new_lines, -1) |
| 406 |
endif |
| 407 |
|
| 408 |
" Vim always has at least one logical line. |
| 409 |
if empty (new_lines) |
| 410 |
let new_lines = [''] |
| 411 |
endif |
| 412 |
|
| 413 |
let hunks = diff (old_lines, new_lines, {'output': 'indices'}) |
| 414 |
|
| 415 |
if empty (hunks) |
| 416 |
return { |
| 417 |
\ 'changedtick': b:changedtick, |
| 418 |
\ 'hunks': 0, |
| 419 |
\ 'noop': v:true, |
| 420 |
\ } |
| 421 |
endif |
| 422 |
|
| 423 |
" Nothing has touched the buffer yet. Recheck immediately before commit. |
| 424 |
call s:rpc_expect_tick (a:expected_changedtick) |
| 425 |
call s:rpc_break_undo () |
| 426 |
|
| 427 |
" diff() indices refer to the original buffer. Apply bottom-up so earlier |
| 428 |
" line numbers never move underneath later hunks. |
| 429 |
let changed = 0 |
| 430 |
|
| 431 |
for hunk in reverse (copy (hunks)) |
| 432 |
let first = hunk.from_idx + 1 |
| 433 |
let old_count = hunk.from_count |
| 434 |
let new_count = hunk.to_count |
| 435 |
let common = min ([old_count, new_count]) |
| 436 |
|
| 437 |
let replacement = new_count |
| 438 |
\ ? new_lines[hunk.to_idx : hunk.to_idx + new_count - 1] |
| 439 |
\ : [] |
| 440 |
|
| 441 |
" Pure insertion: from_idx is the zero-based insertion position. |
| 442 |
if !old_count |
| 443 |
if changed |
| 444 |
undojoin |
| 445 |
endif |
| 446 |
|
| 447 |
if append (first - 1, replacement) |
| 448 |
throw 'rpc/append_failed' |
| 449 |
endif |
| 450 |
|
| 451 |
let changed = 1 |
| 452 |
continue |
| 453 |
endif |
| 454 |
|
| 455 |
if common |
| 456 |
if changed |
| 457 |
undojoin |
| 458 |
endif |
| 459 |
|
| 460 |
if setline (first, replacement[:common - 1]) |
| 461 |
throw 'rpc/setline_failed' |
| 462 |
endif |
| 463 |
|
| 464 |
let changed = 1 |
| 465 |
endif |
| 466 |
|
| 467 |
if new_count < old_count |
| 468 |
if changed |
| 469 |
undojoin |
| 470 |
endif |
| 471 |
|
| 472 |
if deletebufline ( |
| 473 |
\ bufnr ('%'), |
| 474 |
\ first + new_count, |
| 475 |
\ first + old_count - 1 |
| 476 |
\ ) |
| 477 |
throw 'rpc/deletebufline_failed' |
| 478 |
endif |
| 479 |
|
| 480 |
let changed = 1 |
| 481 |
|
| 482 |
elseif new_count > old_count |
| 483 |
if changed |
| 484 |
undojoin |
| 485 |
endif |
| 486 |
|
| 487 |
if append ( |
| 488 |
\ first + old_count - 1, |
| 489 |
\ replacement[old_count:] |
| 490 |
\ ) |
| 491 |
throw 'rpc/append_failed' |
| 492 |
endif |
| 493 |
|
| 494 |
let changed = 1 |
| 495 |
endif |
| 496 |
endfor |
| 497 |
|
| 498 |
return { |
| 499 |
\ 'changedtick': b:changedtick, |
| 500 |
\ 'hunks': len (hunks), |
| 501 |
\ } |
| 502 |
|
| 503 |
finally |
| 504 |
call delete (oldfile) |
| 505 |
call delete (newfile) |
| 506 |
endtry |
| 507 |
endfunction |
| 508 |
|
| 509 |
function! s:rpc_read_ranges (buf, ranges) abort |
| 510 |
let result = [] |
| 511 |
let line_count = s:rpc_bufinfo (a:buf).linecount |
| 512 |
|
| 513 |
for range in a:ranges |
| 514 |
let [first, last] = s:rpc_range (range, line_count) |
| 515 |
call add (result, {'range': range, 'start': first, 'end': last, 'lines': getline (first, last)}) |
| 516 |
endfor |
| 517 |
|
| 518 |
return {'changedtick': b:changedtick, 'ranges': result} |
| 519 |
endfunction |
| 520 |
|
| 521 |
function! s:rpc_call_read_lines (buf, ranges) abort |
| 522 |
return s:rpc_in_buffer (a:buf, {-> s:rpc_read_ranges (a:buf, a:ranges)}) |
| 523 |
endfunction |
| 524 |
|
| 525 |
" :global does the actual scan; this function merely collects matching lines. |
| 526 |
let s:rpc_grep_result = [] |
| 527 |
let s:rpc_grep_pattern = '' |
| 528 |
let s:rpc_grep_max = 0 |
| 529 |
|
| 530 |
function! s:rpc_grep_collect () abort |
| 531 |
if len (s:rpc_grep_result) >= s:rpc_grep_max |
| 532 |
return |
| 533 |
endif |
| 534 |
|
| 535 |
let text = getline ('.') |
| 536 |
|
| 537 |
call add (s:rpc_grep_result, { |
| 538 |
\ 'buffer': bufnr ('%'), |
| 539 |
\ 'changedtick': b:changedtick, |
| 540 |
\ 'line': line ('.'), |
| 541 |
\ 'column': match (text, s:rpc_grep_pattern) + 1, |
| 542 |
\ 'text': text, |
| 543 |
\ }) |
| 544 |
endfunction |
| 545 |
|
| 546 |
function! s:rpc_grep_buffer () abort |
| 547 |
let save_search = @/ |
| 548 |
|
| 549 |
try |
| 550 |
let @/ = s:rpc_grep_pattern |
| 551 |
execute 'silent keepjumps keeppatterns global//call ' . s:rpc_sid . 'rpc_grep_collect()' |
| 552 |
finally |
| 553 |
let @/ = save_search |
| 554 |
endtry |
| 555 |
endfunction |
| 556 |
|
| 557 |
function! s:rpc_call_grep (buffers, pattern, mode, max_results) abort |
| 558 |
if a:max_results <= 0 |
| 559 |
throw 'rpc/bad_max_results: ' . a:max_results |
| 560 |
endif |
| 561 |
|
| 562 |
if a:mode ==# 'regex' |
| 563 |
let s:rpc_grep_pattern = a:pattern |
| 564 |
elseif a:mode ==# 'word' |
| 565 |
let s:rpc_grep_pattern = '\V\<' . escape (a:pattern, '\') . '\>' |
| 566 |
else |
| 567 |
throw 'rpc/bad_grep_mode: ' . a:mode |
| 568 |
endif |
| 569 |
|
| 570 |
let s:rpc_grep_result = [] |
| 571 |
let s:rpc_grep_max = a:max_results |
| 572 |
let buffers = empty (a:buffers) ? map (filter (getbufinfo (), 'v:val.loaded'), 'v:val.bufnr') : a:buffers |
| 573 |
|
| 574 |
for buf in buffers |
| 575 |
call s:rpc_in_buffer (buf, {-> s:rpc_grep_buffer ()}) |
| 576 |
|
| 577 |
if len (s:rpc_grep_result) >= s:rpc_grep_max |
| 578 |
break |
| 579 |
endif |
| 580 |
endfor |
| 581 |
|
| 582 |
return {'matches': s:rpc_grep_result, 'truncated': len (s:rpc_grep_result) >= s:rpc_grep_max} |
| 583 |
endfunction |
| 584 |
|
| 585 |
" Quote / as an Ex :substitute delimiter without changing replacement backslash semantics. |
| 586 |
" A / preceded by an even-length run of backslashes needs one more backslash; |
| 587 |
" with an odd-length run it is already quoted for the command parser. An odd |
| 588 |
" trailing run also needs one more backslash so it cannot quote the closing /. |
| 589 |
function! s:rpc_quote_substitute_replacement (text) abort |
| 590 |
let result = '' |
| 591 |
let backslashes = 0 |
| 592 |
|
| 593 |
for char in split (a:text, '\zs') |
| 594 |
if char ==# '\' |
| 595 |
let backslashes += 1 |
| 596 |
else |
| 597 |
if char ==# '/' && backslashes % 2 == 0 |
| 598 |
let result .= '\' |
| 599 |
endif |
| 600 |
|
| 601 |
let backslashes = 0 |
| 602 |
endif |
| 603 |
|
| 604 |
let result .= char |
| 605 |
endfor |
| 606 |
|
| 607 |
if backslashes % 2 |
| 608 |
let result .= '\' |
| 609 |
endif |
| 610 |
|
| 611 |
return result |
| 612 |
endfunction |
| 613 |
|
| 614 |
function! s:rpc_insert_lines (line, lines, expected_changedtick) abort |
| 615 |
if a:line < 0 || a:line > line ('$') |
| 616 |
throw printf ('rpc/bad_insert_line: %d', a:line) |
| 617 |
endif |
| 618 |
|
| 619 |
call s:rpc_expect_tick (a:expected_changedtick) |
| 620 |
call s:rpc_break_undo () |
| 621 |
|
| 622 |
if append (a:line, a:lines) |
| 623 |
throw 'rpc/append_failed' |
| 624 |
endif |
| 625 |
|
| 626 |
return { |
| 627 |
\ 'changedtick': b:changedtick, |
| 628 |
\ 'start': a:line + 1, |
| 629 |
\ 'end': a:line + len (a:lines), |
| 630 |
\ } |
| 631 |
endfunction |
| 632 |
|
| 633 |
function! s:rpc_call_insert_lines (buf, line, lines, expected_changedtick) abort |
| 634 |
return s:rpc_in_buffer (a:buf, {-> s:rpc_insert_lines ( |
| 635 |
\ a:line, |
| 636 |
\ a:lines, |
| 637 |
\ a:expected_changedtick |
| 638 |
\ )}) |
| 639 |
endfunction |
| 640 |
|
| 641 |
function! s:rpc_substitute (range, pattern, replacement, flags, expected_changedtick) abort |
| 642 |
call s:rpc_expect_tick (a:expected_changedtick) |
| 643 |
call s:rpc_validate_range (a:range) |
| 644 |
|
| 645 |
if a:flags !~# '^[egiInp#lr]*$' |
| 646 |
throw 'rpc/bad_substitute_flags: ' . a:flags |
| 647 |
endif |
| 648 |
|
| 649 |
if strpart (a:replacement, 0, 2) ==# '\=' |
| 650 |
throw 'rpc/expression_replacement_not_allowed' |
| 651 |
endif |
| 652 |
|
| 653 |
let save_search = @/ |
| 654 |
let replacement = s:rpc_quote_substitute_replacement (a:replacement) |
| 655 |
|
| 656 |
call s:rpc_break_undo () |
| 657 |
|
| 658 |
try |
| 659 |
let @/ = a:pattern |
| 660 |
execute 'silent keepjumps keeppatterns ' . a:range . 's//' . replacement . '/' . a:flags |
| 661 |
finally |
| 662 |
let @/ = save_search |
| 663 |
endtry |
| 664 |
|
| 665 |
return {'changedtick': b:changedtick, 'changed': b:changedtick != a:expected_changedtick} |
| 666 |
endfunction |
| 667 |
|
| 668 |
function! s:rpc_call_substitute (buf, range, pattern, replacement, flags, expected_changedtick) abort |
| 669 |
return s:rpc_in_buffer (a:buf, {-> s:rpc_substitute (a:range, a:pattern, a:replacement, a:flags, a:expected_changedtick)}) |
| 670 |
endfunction |
| 671 |
|
| 672 |
function! s:rpc_call_set_cursor (buf, line, col, ...) abort |
| 673 |
let info = s:rpc_bufinfo (a:buf) |
| 674 |
|
| 675 |
if a:0 && a:1 isnot v:null && info.changedtick != a:1 |
| 676 |
throw printf ('rpc/stale_buffer: expected %d, got %d', a:1, info.changedtick) |
| 677 |
endif |
| 678 |
|
| 679 |
if a:line < 1 || a:line > info.linecount || a:col < 1 |
| 680 |
throw printf ('rpc/bad_cursor: %d,%d', a:line, a:col) |
| 681 |
endif |
| 682 |
|
| 683 |
if a:buf != bufnr ('%') |
| 684 |
if !empty (info.windows) |
| 685 |
if !win_gotoid (info.windows[0]) |
| 686 |
throw 'rpc/cannot_enter_window: ' . info.windows[0] |
| 687 |
endif |
| 688 |
else |
| 689 |
execute 'keepalt keepjumps hide buffer ' . a:buf |
| 690 |
endif |
| 691 |
endif |
| 692 |
|
| 693 |
call cursor (a:line, a:col) |
| 694 |
|
| 695 |
return {'buffer': bufnr ('%'), 'changedtick': b:changedtick, 'line': line ('.'), 'column': col ('.')} |
| 696 |
endfunction |
| 697 |
|
| 698 |
""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""" |
| 699 |
" command receiver |
| 700 |
|
| 701 |
function! s:receive (channel, msg) abort |
| 702 |
if type (a:msg) != v:t_list || len (a:msg) == 0 |
| 703 |
return |
| 704 |
endif |
| 705 |
|
| 706 |
let cmd = a:msg[0] |
| 707 |
|
| 708 |
if cmd ==# 'call' |
| 709 |
let id = a:msg[1] |
| 710 |
let method = a:msg[2] |
| 711 |
let args = a:msg[3:] |
| 712 |
|
| 713 |
try |
| 714 |
let result = call (function('s:rpc_call_' . method), args) |
| 715 |
call ch_sendexpr (s:channel, ['reply', id, 1, result]) |
| 716 |
catch |
| 717 |
call ch_sendexpr (s:channel, ['reply', id, 0, v:exception]) " originallky also: v:throwpoint |
| 718 |
endtry |
| 719 |
|
| 720 |
elseif cmd ==# 'open' |
| 721 |
let session = a:msg[1] |
| 722 |
let filename = a:msg[2] |
| 723 |
let extension = a:msg[3] |
| 724 |
let state = a:msg[4] |
| 725 |
|
| 726 |
let path = tempname() . '-' . filename . extension |
| 727 |
let s:incoming = { 'path': path, 'session': session, 'state': state } |
| 728 |
call writefile ([], path) |
| 729 |
|
| 730 |
elseif cmd ==# 'data' |
| 731 |
let data = base64_decode (a:msg[1]) |
| 732 |
call ch_sendexpr (s:channel, ['ack', len (data)]) |
| 733 |
call writefile (data, s:incoming.path, 'ab') |
| 734 |
|
| 735 |
elseif cmd ==# 'ack' |
| 736 |
" nop |
| 737 |
|
| 738 |
elseif cmd ==# 'eof' |
| 739 |
if a:msg[1] |
| 740 |
execute 'edit ' . fnameescape (s:incoming.path) |
| 741 |
let s:sessions[bufnr('%')] = { 'session': s:incoming.session, 'state': s:incoming.state, 'path': s:incoming.path } |
| 742 |
else |
| 743 |
call delete (s:incoming.path) |
| 744 |
endif |
| 745 |
|
| 746 |
let s:incoming = v:null |
| 747 |
|
| 748 |
elseif cmd ==# 'ex' |
| 749 |
execute a:msg[1] |
| 750 |
|
| 751 |
elseif cmd ==# 'echo' |
| 752 |
if s:popup |
| 753 |
call popup_close (s:popup) |
| 754 |
endif |
| 755 |
echom 'mcp-vim: ' . a:msg[1] |
| 756 |
|
| 757 |
elseif cmd ==# 'error' |
| 758 |
if s:popup |
| 759 |
call popup_close (s:popup) |
| 760 |
endif |
| 761 |
let s:popup = popup_create (split (a:msg[1], "\n", 1), { 'line': &lines - 2, 'col': 1, 'pos': 'botleft', 'maxheight': 10, 'maxwidth': &columns - 4, 'scrollbar': 1, 'padding': [0, 1, 0, 1] }) |
| 762 |
|
| 763 |
elseif cmd ==# 'goto' |
| 764 |
for b in keys (s:sessions) |
| 765 |
if s:sessions[b].session ==# a:msg[1] |
| 766 |
execute 'buffer ' . b |
| 767 |
call cursor (a:msg[2], a:msg[3]) |
| 768 |
break |
| 769 |
endif |
| 770 |
endfor |
| 771 |
|
| 772 |
else |
| 773 |
echom 'mcp-vim: unknown command ' . string (cmd) |
| 774 |
endif |
| 775 |
endfunction |
| 776 |
|
| 777 |
function! s:save (file) abort |
| 778 |
if type (s:channel) != v:t_channel |
| 779 |
return |
| 780 |
endif |
| 781 |
|
| 782 |
let b = bufnr ('%') |
| 783 |
|
| 784 |
if has_key (s:sessions, b) && a:file ==# s:sessions[b].path |
| 785 |
call ch_sendexpr (s:channel, ['save', s:sessions[b].session]) |
| 786 |
|
| 787 |
let data = readblob (s:sessions[b].path) |
| 788 |
call ch_sendexpr (s:channel, ['data', base64_encode (data)]) |
| 789 |
call ch_sendexpr (s:channel, ['eof', 1]) |
| 790 |
|
| 791 |
endif |
| 792 |
endfunction |
| 793 |
|
| 794 |
function! s:close (buf) abort |
| 795 |
if has_key (s:sessions, a:buf) |
| 796 |
call delete (s:sessions[a:buf].path) |
| 797 |
if type (s:channel) == v:t_channel |
| 798 |
call ch_sendexpr (s:channel, ['close', s:sessions[a:buf].session]) |
| 799 |
endif |
| 800 |
call remove (s:sessions, a:buf) |
| 801 |
endif |
| 802 |
endfunction |
| 803 |
|
| 804 |
augroup mcp_vim |
| 805 |
autocmd! |
| 806 |
|
| 807 |
autocmd BufWritePost * call s:save (expand ('<afile>:p')) |
| 808 |
autocmd BufUnload * call s:close (str2nr (expand ('<abuf>'))) |
| 809 |
autocmd VimLeavePre * call s:quit () |
| 810 |
augroup END |
| 811 |
|