Skip to content

Commit fe96417

Browse files
committed
fix: use remote server for executing commands
1 parent ce4b104 commit fe96417

File tree

3 files changed

+244
-38
lines changed

3 files changed

+244
-38
lines changed

README.md

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
# live-command.nvim
2-
![version](https://img.shields.io/badge/version-1.2.1-brightgreen)
2+
![version](https://img.shields.io/badge/version-1.3.0-brightgreen)
33

44
Text editing in Neovim with immediate visual feedback: view the effects of any command on your buffer contents live. Preview macros, the `:norm` command & more!
55

@@ -9,24 +9,26 @@ Text editing in Neovim with immediate visual feedback: view the effects of any c
99
<p><sub>Theme: <a href="https://github.com/folke/tokyonight.nvim">tokyonight.nvim</a></sub></p>
1010

1111
## :sparkles: Motivation and Features
12-
In version 0.8, Neovim has introduced the `command-preview` feature.
13-
Contrary to what "command preview" suggests, previewing any given
14-
command does not work out of the box: you need to manually update the buffer text and set
15-
highlights *for every command*.
12+
In Neovim version 0.8, the `command-preview` feature has been introduced.
13+
Despite its name, it does not enable automatic previewing of any command.
14+
Instead, users must manually update the buffer text and set highlights *for each command*.
1615

17-
This plugin tries to change that: it provides a **simple API for creating previewable commands**
18-
in Neovim. Just specify the command you want to run and live-command will do all the
19-
work for you. This includes viewing **individual insertions, changes and deletions** as you
20-
type.
16+
This plugin aims to address this issue by offering a **simple API for creating previewable commands**
17+
in Neovim. Simply provide the command you want to preview and live-command will do all the
18+
work for you. This includes viewing **individual insertions, changes and deletions** as you type.
19+
20+
After the most recent update, live-command now spawns a separate Neovim instance to execute commands.
21+
This avoids many issues encountered when running the command directly in the current Neovim instance
22+
([#6](https://github.com/smjonas/live-command.nvim/issues/6), [#16](https://github.com/smjonas/live-command.nvim/issues/16), [#24](https://github.com/smjonas/live-command.nvim/issues/24), [#28](https://github.com/smjonas/live-command.nvim/issues/28)).
2123

2224
## Requirements
2325
Neovim 0.8+
2426

2527
## :rocket: Getting started
2628
Install using your favorite package manager and call the setup function with a table of
27-
commands to create. Here is an example that creates a previewable `:Norm` command:
29+
commands to create. Here is an example for `lazy.nvim` that creates a previewable `:Norm` command:
2830
```lua
29-
use {
31+
{
3032
"smjonas/live-command.nvim",
3133
-- live-command supports semantic versioning via tags
3234
-- tag = "1.*",
@@ -50,7 +52,7 @@ Here is a list of available settings:
5052
| ----------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------
5153
| cmd | string | The name of an existing command to preview.
5254
| args | string? \| function(arg: string?, opts: table) -> string | Arguments passed to the command. If a function, takes in the options passed to the command and must return the transformed argument(s) `cmd` will be called with. `opts` has the same structure as the `opts` table passed to the `nvim_create_user_command` callback function. If `nil`, the arguments are supplied from the command-line while the user is typing the command.
53-
| range | string? | The range to prepend to the command. Set this to `""` if you don't want the new command to receive a count, e.g. when turning `:9Reg a` into `:norm 9@a`. If `nil`, the range will be supplied from the command entered.
55+
| range | string? | The range to prepend to the command. Set this to `""` if you don't want the new command to receive a count, e.g. when turning `:9Reg a` into `:norm 9@a`. If `nil`, the range will be supplied from the command entered.
5456

5557
### Example
5658
The following example creates a `:Reg` command which allows you to preview the effects of macros (e.g. `:5Reg a` to run macro `a` five times).

lua/live-command/init.lua

Lines changed: 77 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,14 @@ M.defaults = {
1010
},
1111
}
1212

13+
local api = vim.api
14+
---@class Remote
15+
local remote
16+
17+
---@type number?
18+
local chan_id
19+
20+
local cursor_row, cursor_col
1321
local should_cache_lines = true
1422
local cached_lines
1523
local prev_lazyredraw
@@ -85,6 +93,7 @@ local function add_inline_highlights(line, cached_lns, updated_lines, undo_delet
8593
-- Observation: when changing "line" to "tes", there should not be an offset (-2)
8694
-- after changing "lin" to "t" (because we are not modifying the line)
8795
highlight.column = highlight.column + col_offset
96+
highlight.hunk = nil
8897
table.insert(highlights, highlight)
8998

9099
if defer then
@@ -176,13 +185,15 @@ end
176185
-- Expose functions to tests
177186
M._preview_across_lines = get_diff_highlights
178187

179-
local function run_buf_cmd(buf, cmd)
180-
vim.api.nvim_buf_call(buf, function()
181-
log(function()
182-
return ("Previewing command: %s (current line = %d)"):format(cmd, vim.api.nvim_win_get_cursor(0)[1])
183-
end)
184-
vim.cmd(cmd)
188+
---@param cmd string
189+
local function run_cmd(cmd)
190+
local cursor_pos = api.nvim_win_get_cursor(0)
191+
cursor_row, cursor_col = cursor_pos[1], cursor_pos[2]
192+
193+
log(function()
194+
return ("Previewing command: %s (l=%d,c=%d)"):format(cmd, cursor_row, cursor_col)
185195
end)
196+
return remote.run_cmd(chan_id, cmd, cursor_row, cursor_col)
186197
end
187198

188199
-- Called when the user is still typing the command or the command arguments
@@ -194,11 +205,11 @@ local function command_preview(opts, preview_ns, preview_buf)
194205
local args = opts.cmd_args
195206
local command = opts.command
196207

197-
local bufnr = vim.api.nvim_get_current_buf()
208+
local bufnr = api.nvim_get_current_buf()
198209
if should_cache_lines then
199210
prev_lazyredraw = vim.o.lazyredraw
200211
vim.o.lazyredraw = true
201-
cached_lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
212+
cached_lines = api.nvim_buf_get_lines(bufnr, 0, -1, false)
202213
should_cache_lines = false
203214
end
204215

@@ -207,10 +218,11 @@ local function command_preview(opts, preview_ns, preview_buf)
207218
local prev_errmsg = vim.v.errmsg
208219
local visible_line_range = { vim.fn.line("w0"), vim.fn.line("w$") }
209220

221+
local updated_lines
210222
if opts.line1 == opts.line2 then
211-
run_buf_cmd(bufnr, ("%s %s"):format(command.cmd, args))
223+
updated_lines = run_cmd(("%s %s"):format(command.cmd, args))
212224
else
213-
run_buf_cmd(bufnr, ("%d,%d%s %s"):format(opts.line1, opts.line2, command.cmd, args))
225+
updated_lines = run_cmd(("%d,%d%s %s"):format(opts.line1, opts.line2, command.cmd, args))
214226
end
215227

216228
vim.v.errmsg = prev_errmsg
@@ -220,12 +232,11 @@ local function command_preview(opts, preview_ns, preview_buf)
220232
math.max(visible_line_range[2], vim.fn.line("w$")),
221233
}
222234

223-
local updated_lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
224235
local set_lines = function(lines)
225236
-- TODO: is this worth optimizing?
226-
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines)
237+
api.nvim_buf_set_lines(bufnr, 0, -1, false, lines)
227238
if preview_buf then
228-
vim.api.nvim_buf_set_lines(preview_buf, 0, -1, false, lines)
239+
api.nvim_buf_set_lines(preview_buf, 0, -1, false, lines)
229240
end
230241
end
231242

@@ -255,7 +266,7 @@ local function command_preview(opts, preview_ns, preview_buf)
255266
for _, hl in ipairs(highlights) do
256267
local hl_group = command.hl_groups[hl.kind]
257268
if hl_group ~= false then
258-
vim.api.nvim_buf_add_highlight(
269+
api.nvim_buf_add_highlight(
259270
bufnr,
260271
preview_ns,
261272
hl_group,
@@ -285,7 +296,7 @@ end
285296
local create_user_commands = function(commands)
286297
for name, command in pairs(commands) do
287298
local args, range
288-
vim.api.nvim_create_user_command(name, function(opts)
299+
api.nvim_create_user_command(name, function(opts)
289300
local range_string = range and range
290301
or (
291302
opts.range == 2 and ("%s,%s"):format(opts.line1, opts.line2)
@@ -341,7 +352,51 @@ local validate_config = function(config)
341352
end
342353
end
343354

355+
local create_autocmds = function()
356+
local id = api.nvim_create_augroup("command_preview.nvim", { clear = true })
357+
358+
api.nvim_create_autocmd("CmdlineEnter", {
359+
group = id,
360+
callback = function()
361+
remote.sync(chan_id)
362+
end,
363+
})
364+
365+
-- We need to be able to tell when the command was cancelled so the buffer lines are refetched next time.
366+
api.nvim_create_autocmd("CmdLineLeave", {
367+
group = id,
368+
-- Schedule wrap to run after a potential command execution
369+
callback = vim.schedule_wrap(function()
370+
restore_buffer_state()
371+
end),
372+
})
373+
374+
api.nvim_create_autocmd("VimLeavePre", {
375+
group = id,
376+
callback = function()
377+
if chan_id then
378+
vim.fn.chanclose(chan_id)
379+
end
380+
end,
381+
})
382+
383+
api.nvim_create_autocmd({ "TextChanged", "TextChangedI", "BufEnter" }, {
384+
group = id,
385+
callback = remote.on_buffer_updated,
386+
})
387+
388+
api.nvim_create_autocmd("BufWritePost", {
389+
group = id,
390+
callback = remote.on_buffer_saved,
391+
})
392+
end
393+
344394
M.setup = function(user_config)
395+
-- Avoid an infinite loop when invoked from a child process
396+
if vim.env.LIVECOMMAND_NVIM_SERVER == "1" then
397+
return
398+
end
399+
345400
if vim.fn.has("nvim-0.8.0") ~= 1 then
346401
vim.notify(
347402
"[live-command] This plugin requires at least Neovim 0.8. Please upgrade your Neovim version.",
@@ -353,20 +408,16 @@ M.setup = function(user_config)
353408
local config = vim.tbl_deep_extend("force", M.defaults, user_config or {})
354409
validate_config(config)
355410
create_user_commands(config.commands)
411+
remote = require("live-command.remote")
356412

357-
local id = vim.api.nvim_create_augroup("command_preview.nvim", { clear = true })
358-
-- We need to be able to tell when the command was cancelled so the buffer lines are refetched next time.
359-
vim.api.nvim_create_autocmd({ "CmdLineLeave" }, {
360-
group = id,
361-
-- Schedule wrap to run after a potential command execution
362-
callback = vim.schedule_wrap(function()
363-
restore_buffer_state()
364-
end),
365-
})
413+
remote.init_rpc(function(id)
414+
chan_id = id
415+
end)
416+
create_autocmds()
366417

367418
M.debug = user_config.debug
368419

369-
vim.api.nvim_create_user_command("LiveCommandLog", function()
420+
api.nvim_create_user_command("LiveCommandLog", function()
370421
local msg = ("live-command log\n================\n\n%s%s"):format(
371422
logs.ERROR and "[ERROR]\n" .. table.concat(logs.ERROR, "\n") .. (logs.TRACE and "\n" or "") or "",
372423
logs.TRACE and "[TRACE]\n" .. table.concat(logs.TRACE, "\n") or ""
@@ -375,6 +426,6 @@ M.setup = function(user_config)
375426
end, { nargs = 0 })
376427
end
377428

378-
M.version = "1.2.1"
429+
M.version = "1.3.0"
379430

380431
return M

lua/live-command/remote.lua

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
---@class Remote
2+
local M = {}
3+
4+
local tmp_file = os.tmpname()
5+
local uv = vim.loop
6+
local dirty = true
7+
8+
---@type number
9+
local reference_time
10+
11+
local REFERENCE_TIME_OFFSET = 1
12+
13+
---@param server_address string
14+
---@param on_chan_id fun(chan_id:number)
15+
---@param num_retries number
16+
local try_connect = function(server_address, on_chan_id, num_retries)
17+
local ok, chan_id
18+
for i = 0, num_retries do
19+
ok, chan_id = pcall(vim.fn.sockconnect, "pipe", server_address, { rpc = true })
20+
if ok then
21+
reference_time = uv.fs_stat(tmp_file).mtime.sec
22+
assert(reference_time)
23+
on_chan_id(chan_id)
24+
return true
25+
end
26+
if i ~= num_retries then
27+
vim.wait(10)
28+
end
29+
end
30+
return false
31+
end
32+
33+
--- Starts a new Nvim instance and connects to it via RPC.
34+
---@param on_chan_id fun(chan_id: number)
35+
M.init_rpc = function(on_chan_id)
36+
local basename = vim.fs.normalize(vim.fn.stdpath("cache"))
37+
local server_address = basename .. "/live_command_server.pipe"
38+
39+
-- Try to connect to an existing server that has already been spawned
40+
local success = try_connect(server_address, on_chan_id, 1)
41+
if success then
42+
return
43+
end
44+
45+
-- Use environment variables from parent process
46+
local env = { "LIVECOMMAND_NVIM_SERVER=1" }
47+
for k, v in pairs(uv.os_environ()) do
48+
table.insert(env, k .. "=" .. v)
49+
end
50+
51+
local handle
52+
handle, _ = uv.spawn(
53+
vim.v.progpath,
54+
{
55+
args = { "--listen", server_address, "-n" },
56+
env = env,
57+
cwd = vim.fn.getcwd(),
58+
},
59+
vim.schedule_wrap(function(_, _) -- on exit
60+
handle:close()
61+
end)
62+
)
63+
assert(handle)
64+
success = try_connect(server_address, on_chan_id, 100)
65+
if not success then
66+
vim.notify("[live-command.nvim] failed to connect to remote Neovim instance after 1000 ms", vim.log.levels.ERROR)
67+
end
68+
end
69+
70+
-- The global dirty flag indicates that other spawned Nvim instances need to reload the
71+
-- temp file. The flag is encoded by increasing the modified time of the temp file.
72+
local set_global_dirty_flag = function()
73+
local stat = uv.fs_stat(tmp_file)
74+
uv.fs_utime(tmp_file, stat.atime.nsec, reference_time + REFERENCE_TIME_OFFSET)
75+
local new_stat = uv.fs_stat(tmp_file)
76+
assert(new_stat.atime.nsec == stat.atime.nsec)
77+
end
78+
79+
---@return boolean
80+
local check_global_dirty_flag = function()
81+
local stat = uv.fs_stat(tmp_file)
82+
local x = stat.mtime.sec == reference_time + REFERENCE_TIME_OFFSET
83+
if x then
84+
print("DIRRY")
85+
end
86+
return x
87+
end
88+
89+
M.on_buffer_updated = function()
90+
dirty = true
91+
end
92+
93+
M.on_buffer_saved = function()
94+
-- Writing a buffer to disk sets the global dirty flag
95+
if dirty then
96+
set_global_dirty_flag()
97+
end
98+
end
99+
100+
---@param chan_id number?
101+
M.sync = function(chan_id)
102+
-- Child instance has not been created yet
103+
if not chan_id then
104+
return
105+
end
106+
107+
dirty = dirty or check_global_dirty_flag()
108+
if dirty then
109+
print("dirty")
110+
-- Synchronize buffers by writing out the current buffer contents to a temporary file.
111+
-- Remove A and F option values to not affect the alternate file and the buffer name.
112+
vim.cmd("let c=&cpoptions | set cpoptions-=A | set cpoptions-=F | silent w! " .. tmp_file .. " | let &cpoptions=c")
113+
vim.rpcrequest(
114+
chan_id,
115+
"nvim_exec",
116+
-- Store the current sequence number that can be reverted back to
117+
("e! %s | lua vim.g._seq_cur = vim.fn.undotree().seq_cur"):format(tmp_file),
118+
false
119+
)
120+
dirty = false
121+
end
122+
end
123+
124+
--- Runs a command on the remote server and returns the updates buffer lines.
125+
--- This is superior to running vim.cmd from the preview callback in the original Nvim instance
126+
--- as that has a lot of side effects, e.g. https://github.com/neovim/neovim/issues/21495.
127+
---@param chan_id number
128+
---@param cmd string
129+
---@param cursor_row number
130+
---@param cursor_col number
131+
M.run_cmd = function(chan_id, cmd, cursor_row, cursor_col)
132+
-- Restore the buffer state since the last write and move the cursor to the correct position
133+
vim.rpcrequest(
134+
chan_id,
135+
"nvim_exec_lua",
136+
("vim.api.nvim_win_set_cursor(0, {%d, %d})"):format(cursor_row, cursor_col),
137+
{}
138+
)
139+
-- Execute the command asynchronously using rpcnotify as it may block
140+
vim.rpcnotify(chan_id, "nvim_exec", cmd, false)
141+
142+
return vim.rpcrequest(
143+
chan_id,
144+
"nvim_exec_lua",
145+
[[local lines = vim.api.nvim_buf_get_lines(0, 0, -1, false)
146+
vim.cmd.undo({count = vim.g._seq_cur})
147+
return lines
148+
]],
149+
{}
150+
)
151+
end
152+
153+
return M

0 commit comments

Comments
 (0)