diff --git a/README.md b/README.md index 4d8f1ca..fba4a87 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ # live-command.nvim -![version](https://img.shields.io/badge/version-1.2.1-brightgreen) +![version](https://img.shields.io/badge/version-1.3.0-brightgreen) 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! @@ -7,24 +7,26 @@ Text editing in Neovim with immediate visual feedback: view the effects of any c

Theme: tokyonight.nvim

## :sparkles: Motivation and Features -In version 0.8, Neovim has introduced the `command-preview` feature. -Contrary to what "command preview" suggests, previewing any given -command does not work out of the box: you need to manually update the buffer text and set -highlights *for every command*. +In Neovim version 0.8, the `command-preview` feature has been introduced. +Despite its name, it does not enable automatic previewing of any command. +Instead, users must manually update the buffer text and set highlights *for each command*. -This plugin tries to change that: it provides a **simple API for creating previewable commands** -in Neovim. Just specify the command you want to run and live-command will do all the -work for you. This includes viewing **individual insertions, changes and deletions** as you -type. +This plugin aims to address this issue by offering a **simple API for creating previewable commands** +in Neovim. Simply provide the command you want to preview and live-command will do all the +work for you. This includes viewing **individual insertions, changes and deletions** as you type. + +After the most recent update, live-command now spawns a separate Neovim instance to execute commands. +This avoids many issues encountered when running the command directly in the current Neovim instance +([#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)). ## Requirements Neovim 0.8+ ## :rocket: Getting started Install using your favorite package manager and call the setup function with a table of -commands to create. Here is an example that creates a previewable `:Norm` command: +commands to create. Here is an example for `lazy.nvim` that creates a previewable `:Norm` command: ```lua -use { +{ "smjonas/live-command.nvim", -- live-command supports semantic versioning via tags -- tag = "1.*", @@ -84,7 +86,6 @@ require("live-command").setup { change = "DiffChange", }, }, - debug = false, } ``` @@ -119,15 +120,6 @@ deletion edits will not be undone which is otherwise done to make the text chang --- -`debug: boolean` - -Default: `false` - -If `true`, more stuff (not only errors) will be logged. After previewing a command, -you can view the log by running `:LiveCommandLog`. - ---- - Like this project? Give it a :star: to show your support! Also consider checking out my other plugin [inc-rename.nvim](https://github.com/smjonas/inc-rename.nvim), diff --git a/lua/live-command/init.lua b/lua/live-command/init.lua index fc23fd8..11cf631 100644 --- a/lua/live-command/init.lua +++ b/lua/live-command/init.lua @@ -10,22 +10,22 @@ M.defaults = { }, } +local api = vim.api + +---@type Logger +local logger + +---@class Remote +local remote + +---@type number? +local chan_id + +local cursor_row, cursor_col local should_cache_lines = true local cached_lines local prev_lazyredraw -local logs = {} -local function log(msg, level) - level = level or "TRACE" - if M.debug or level ~= "TRACE" then - msg = type(msg) == "function" and msg() or msg - logs[level] = logs[level] or {} - for _, line in ipairs(vim.split(msg .. "\n", "\n")) do - table.insert(logs[level], line) - end - end -end - -- Inserts str_2 into str_1 at the given position. local function string_insert(str_1, str_2, pos) return str_1:sub(1, pos - 1) .. str_2 .. str_1:sub(pos) @@ -47,7 +47,7 @@ local function add_inline_highlights(line, cached_lns, updated_lines, undo_delet local line_a = splice(cached_lns[line]) local line_b = splice(updated_lines[line]) local line_diff = vim.diff(line_a, line_b, { result_type = "indices" }) - log(function() + logger.trace(function() return ("Changed lines (line %d):\nOriginal: '%s' (len=%d)\nUpdated: '%s' (len=%d)\n\nInline hunks: %s"):format( line, cached_lns[line], @@ -85,6 +85,7 @@ local function add_inline_highlights(line, cached_lns, updated_lines, undo_delet -- Observation: when changing "line" to "tes", there should not be an offset (-2) -- after changing "lin" to "t" (because we are not modifying the line) highlight.column = highlight.column + col_offset + highlight.hunk = nil table.insert(highlights, highlight) if defer then @@ -104,10 +105,10 @@ local function get_diff_highlights(cached_lns, updated_lines, line_range, opts) local hunks = vim.diff(table.concat(cached_lns, "\n"), table.concat(updated_lines, "\n"), { result_type = "indices", }) - log(("Visible line range: %d-%d"):format(line_range[1], line_range[2])) + logger.trace(("Visible line range: %d-%d"):format(line_range[1], line_range[2])) for i, hunk in ipairs(hunks) do - log(function() + logger.trace(function() return ("Hunk %d/%d: %s"):format(i, #hunks, vim.inspect(hunk)) end) @@ -123,7 +124,7 @@ local function get_diff_highlights(cached_lns, updated_lines, line_range, opts) end_line = start_line + (count_a - count_b) - 1 end - log(function() + logger.trace(function() return ("Lines %d-%d:\nOriginal: %s\nUpdated: %s"):format( start_line, end_line, @@ -176,13 +177,20 @@ end -- Expose functions to tests M._preview_across_lines = get_diff_highlights -local function run_buf_cmd(buf, cmd) - vim.api.nvim_buf_call(buf, function() - log(function() - return ("Previewing command: %s (current line = %d)"):format(cmd, vim.api.nvim_win_get_cursor(0)[1]) - end) - vim.cmd(cmd) +---@param cmd string +local function run_cmd(cmd) + if not chan_id then + logger.trace("run_cmd: skipped as chan_id is not set") + return + end + + local cursor_pos = api.nvim_win_get_cursor(0) + cursor_row, cursor_col = cursor_pos[1], cursor_pos[2] + + logger.trace(function() + return ("Previewing command: %s (l=%d,c=%d)"):format(cmd, cursor_row, cursor_col) end) + return remote.run_cmd(chan_id, cmd, cursor_row, cursor_col) end -- Called when the user is still typing the command or the command arguments @@ -190,15 +198,14 @@ local function command_preview(opts, preview_ns, preview_buf) -- Any errors that occur in the preview function are not directly shown to the user but stored in vim.v.errmsg. -- Related: https://github.com/neovim/neovim/issues/18910. vim.v.errmsg = "" - logs = {} local args = opts.cmd_args local command = opts.command - local bufnr = vim.api.nvim_get_current_buf() + local bufnr = api.nvim_get_current_buf() if should_cache_lines then prev_lazyredraw = vim.o.lazyredraw vim.o.lazyredraw = true - cached_lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + cached_lines = api.nvim_buf_get_lines(bufnr, 0, -1, false) should_cache_lines = false end @@ -207,10 +214,11 @@ local function command_preview(opts, preview_ns, preview_buf) local prev_errmsg = vim.v.errmsg local visible_line_range = { vim.fn.line("w0"), vim.fn.line("w$") } + local updated_lines if opts.line1 == opts.line2 then - run_buf_cmd(bufnr, ("%s %s"):format(command.cmd, args)) + updated_lines = run_cmd(("%s %s"):format(command.cmd, args)) else - run_buf_cmd(bufnr, ("%d,%d%s %s"):format(opts.line1, opts.line2, command.cmd, args)) + updated_lines = run_cmd(("%d,%d%s %s"):format(opts.line1, opts.line2, command.cmd, args)) end vim.v.errmsg = prev_errmsg @@ -220,12 +228,11 @@ local function command_preview(opts, preview_ns, preview_buf) math.max(visible_line_range[2], vim.fn.line("w$")), } - local updated_lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) local set_lines = function(lines) -- TODO: is this worth optimizing? - vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) + api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) if preview_buf then - vim.api.nvim_buf_set_lines(preview_buf, 0, -1, false, lines) + api.nvim_buf_set_lines(preview_buf, 0, -1, false, lines) end end @@ -233,7 +240,7 @@ local function command_preview(opts, preview_ns, preview_buf) set_lines(updated_lines) -- This should not happen if not opts.line1 then - log("No line1 range provided", "ERROR") + logger.error("No line1 range provided") end return 2 end @@ -247,7 +254,7 @@ local function command_preview(opts, preview_ns, preview_buf) undo_deletions = command.hl_groups["deletion"] ~= false, inline_highlighting = command.inline_highlighting, }) - log(function() + logger.trace(function() return "Highlights: " .. vim.inspect(highlights) end) @@ -255,7 +262,7 @@ local function command_preview(opts, preview_ns, preview_buf) for _, hl in ipairs(highlights) do local hl_group = command.hl_groups[hl.kind] if hl_group ~= false then - vim.api.nvim_buf_add_highlight( + api.nvim_buf_add_highlight( bufnr, preview_ns, hl_group, @@ -272,12 +279,12 @@ local function restore_buffer_state() vim.o.lazyredraw = prev_lazyredraw should_cache_lines = true if vim.v.errmsg ~= "" then - log(("An error occurred in the preview function:\n%s"):format(vim.inspect(vim.v.errmsg)), "ERROR") + logger.error(("An error occurred in the preview function:\n%s"):format(vim.inspect(vim.v.errmsg))) end end local function execute_command(command) - log("Executing command: " .. command) + logger.trace("Executing command: " .. command) vim.cmd(command) restore_buffer_state() end @@ -285,7 +292,7 @@ end local create_user_commands = function(commands) for name, command in pairs(commands) do local args, range - vim.api.nvim_create_user_command(name, function(opts) + api.nvim_create_user_command(name, function(opts) local range_string = range and range or ( opts.range == 2 and ("%s,%s"):format(opts.line1, opts.line2) @@ -341,7 +348,57 @@ local validate_config = function(config) end end +local create_autocmds = function() + local id = api.nvim_create_augroup("command_preview.nvim", { clear = true }) + + api.nvim_create_autocmd("CmdlineEnter", { + group = id, + callback = function() + remote.init_rpc(logger, function(chan_id_) + chan_id = chan_id_ + end) + end, + once = true, + }) + + api.nvim_create_autocmd("CmdlineEnter", { + group = id, + callback = function() + remote.sync(chan_id) + end, + }) + + -- We need to be able to tell when the command was cancelled so the buffer lines are refetched next time + api.nvim_create_autocmd("CmdLineLeave", { + group = id, + -- Schedule wrap to run after a potential command execution + callback = vim.schedule_wrap(function() + restore_buffer_state() + end), + }) + + api.nvim_create_autocmd("VimLeavePre", { + group = id, + callback = function() + if chan_id then + vim.fn.chanclose(chan_id) + end + end, + }) + + -- Setting dirty = true on FocusGained is important with multiple Nvim instances + api.nvim_create_autocmd({ "TextChanged", "TextChangedI", "BufEnter", "FocusGained" }, { + group = id, + callback = remote.on_buffer_updated, + }) +end + M.setup = function(user_config) + -- Avoid an infinite loop when invoked from a child process + if vim.env.LIVECOMMAND_NVIM_SERVER == "1" then + return + end + if vim.fn.has("nvim-0.8.0") ~= 1 then vim.notify( "[live-command] This plugin requires at least Neovim 0.8. Please upgrade your Neovim version.", @@ -353,28 +410,16 @@ M.setup = function(user_config) local config = vim.tbl_deep_extend("force", M.defaults, user_config or {}) validate_config(config) create_user_commands(config.commands) + logger = require("live-command.logger") + remote = require("live-command.remote") + create_autocmds() +end - local id = vim.api.nvim_create_augroup("command_preview.nvim", { clear = true }) - -- We need to be able to tell when the command was cancelled so the buffer lines are refetched next time. - vim.api.nvim_create_autocmd({ "CmdLineLeave" }, { - group = id, - -- Schedule wrap to run after a potential command execution - callback = vim.schedule_wrap(function() - restore_buffer_state() - end), - }) - - M.debug = user_config.debug - - vim.api.nvim_create_user_command("LiveCommandLog", function() - local msg = ("live-command log\n================\n\n%s%s"):format( - logs.ERROR and "[ERROR]\n" .. table.concat(logs.ERROR, "\n") .. (logs.TRACE and "\n" or "") or "", - logs.TRACE and "[TRACE]\n" .. table.concat(logs.TRACE, "\n") or "" - ) - vim.notify(msg) - end, { nargs = 0 }) +---@param logger_ Logger +M._set_logger = function(logger_) + logger = logger_ end -M.version = "1.2.1" +M.version = "1.3.0" return M diff --git a/lua/live-command/logger.lua b/lua/live-command/logger.lua new file mode 100644 index 0000000..edb92d8 --- /dev/null +++ b/lua/live-command/logger.lua @@ -0,0 +1,38 @@ +---@class Logger +local M = {} + +---@type Log[] +local logs = {} + +---@class Log +---@field msg string +---@field level number + +---@param msg string|fun():string +M.trace = function(msg) + msg = type(msg) == "function" and msg() or msg + table.insert(logs, { msg = msg, level = vim.log.levels.TRACE }) +end + +---@param msg string|fun():string +M.error = function(msg) + msg = type(msg) == "function" and msg() or msg + table.insert(logs, { msg = msg, level = vim.log.levels.ERROR }) +end + +vim.api.nvim_create_user_command("LiveCommandLog", function() + local msgs = {} + for i, log in ipairs(logs) do + local level = "" + if log.level == vim.log.levels.TRACE then + level = "[TRACE] " + elseif log.level == vim.log.levels.ERROR then + level = "[ERROR] " + end + msgs[i] = level .. log.msg + end + + vim.notify(table.concat(msgs, "\n")) +end, { nargs = 0 }) + +return M diff --git a/lua/live-command/remote.lua b/lua/live-command/remote.lua new file mode 100644 index 0000000..d628d87 --- /dev/null +++ b/lua/live-command/remote.lua @@ -0,0 +1,168 @@ +---@class Remote +local M = {} + +---@type Logger +local logger + +local uv = vim.loop +local tmp_file = os.tmpname() +local dirty = true + +---@type table +local cur_marks + +---@param server_address string +---@param on_chan_id fun(chan_id:number) +---@param num_retries number +local try_connect = function(server_address, on_chan_id, num_retries) + local ok, chan_id + for i = 0, num_retries do + ok, chan_id = pcall(vim.fn.sockconnect, "pipe", server_address, { rpc = true }) + if ok then + on_chan_id(chan_id) + return true + end + if i ~= num_retries then + vim.wait(10) + end + end + return false +end + +--- Starts a new Nvim instance and connects to it via RPC. +---@param logger_ Logger +---@param on_chan_id fun(chan_id: number) +M.init_rpc = function(logger_, on_chan_id) + logger = logger_ + local basename = vim.fs.normalize(vim.fn.stdpath("cache")) + local server_address = basename .. "/live_command_server_%d.pipe" + + -- Try to connect to an existing server that has already been spawned + local success = try_connect(server_address, on_chan_id, 1) + if success then + logger.trace("init_rpc: connected to existing server") + return + end + + logger.trace("init_rpc: spawning new server") + + -- Use environment variables from parent process + local env = { "LIVECOMMAND_NVIM_SERVER=1" } + for k, v in pairs(uv.os_environ()) do + table.insert(env, k .. "=" .. v) + end + + local handle + handle, _ = uv.spawn( + vim.v.progpath, + { + args = { "--listen", server_address, "-n" }, + env = env, + cwd = vim.fn.getcwd(), + }, + vim.schedule_wrap(function(_, _) -- on exit + handle:close() + end) + ) + + assert(handle) + success = try_connect(server_address, on_chan_id, 100) + + if success then + logger.trace("init_rpc: connected to server") + else + vim.notify("[live-command.nvim] failed to connect to remote Neovim instance after 1000 ms", vim.log.levels.ERROR) + end +end + +M.on_buffer_updated = function() + dirty = true +end + +--- Called by the remote Nvim instance to set the marks. +---@param marks table +M.receive_marks = function(marks) + local bufnr = vim.api.nvim_get_current_buf() + for _, mark in ipairs(marks) do + -- Remove first char ' to get mark name, use pcall as sometimes marks fail to be set + pcall(vim.api.nvim_buf_set_mark, bufnr, mark.name:sub(2), mark.lnum, mark.col - 1, {}) + end +end + +--- Synchronizes the current local marks so that the remote instance +--- has the correct mark positions. +---@param chan_id number +local sync_local_marks = function(chan_id) + local diff = {} + cur_marks = cur_marks or {} + + -- Index by mark name for easier access + local new_marks = {} + for _, entry in ipairs(vim.fn.getmarklist(vim.api.nvim_get_current_buf())) do + new_marks[entry.mark] = entry + end + + -- Collect all marks that haven't been synced + for mark, entry in pairs(new_marks) do + local new_pos = entry.pos + local cur_pos = cur_marks[mark] and cur_marks[mark].pos + if not cur_pos or cur_pos[1] ~= new_pos[1] or cur_pos[2] ~= new_pos[2] or cur_pos[3] ~= new_pos[3] then + table.insert(diff, { name = mark, lnum = new_pos[2], col = new_pos[3] }) + end + end + + if next(diff) ~= nil then + vim.rpcrequest(chan_id, "nvim_exec_lua", "require('live-command.remote').receive_marks(...)", { diff }) + end + cur_marks = new_marks +end + +--- Called when the user enters the command line. +---@param chan_id number? +M.sync = function(chan_id) + -- Child instance has not been created yet + if not chan_id then + return + end + + if dirty then + -- Synchronize buffers by writing out the current buffer contents to a temporary file. + -- Remove A and F option values to not affect the alternate file and the buffer name. + vim.cmd("let c=&cpoptions | set cpoptions-=A | set cpoptions-=F | silent w! " .. tmp_file .. " | let &cpoptions=c") + vim.rpcrequest( + chan_id, + "nvim_exec", + -- Store the current sequence number that can be reverted back to + ("e! %s | lua vim.g._seq_cur = vim.fn.undotree().seq_cur"):format(tmp_file), + false + ) + dirty = false + end + sync_local_marks(chan_id) +end + +--- Runs a command on the remote server and returns the updates buffer lines. +--- This is superior to running vim.cmd from the preview callback in the original Nvim instance +--- as that has a lot of side effects, e.g. https://github.com/neovim/neovim/issues/21495. +---@param chan_id number +---@param cmd string +---@param cursor_row number +---@param cursor_col number +M.run_cmd = function(chan_id, cmd, cursor_row, cursor_col) + -- Move the cursor to the correct position + vim.rpcnotify(chan_id, "nvim_exec_lua", "vim.api.nvim_win_set_cursor(...)", { 0, { cursor_row, cursor_col } }) + -- Execute the command + vim.rpcnotify(chan_id, "nvim_exec", cmd, false) + + return vim.rpcrequest( + chan_id, + "nvim_exec_lua", + [[local lines = vim.api.nvim_buf_get_lines(0, 0, -1, false) + vim.cmd.undo({count = vim.g._seq_cur}) + return lines + ]], + {} + ) +end + +return M diff --git a/tests/init_spec.lua b/tests/init_spec.lua index f38e817..123f2a4 100644 --- a/tests/init_spec.lua +++ b/tests/init_spec.lua @@ -1,6 +1,10 @@ local live_command = require("live-command") describe("inline_highlights", function() + setup(function() + live_command._set_logger(require("live-command.logger")) + end) + -- Checks for the case when the end of the line was unchanged it("single insertion", function() local highlights = {} @@ -56,7 +60,7 @@ describe("inline_highlights", function() { kind = "change", line = 1, column = 1, length = 4 }, { kind = "change", line = 1, column = 8, length = 1 }, }, highlights) - assert.are_same({"test = Function()"}, updated_lines) + assert.are_same({ "test = Function()" }, updated_lines) end) it("change should not use negative column values", function() @@ -81,7 +85,7 @@ describe("inline_highlights", function() { kind = "deletion", line = 1, column = 3, length = 2 }, { kind = "deletion", line = 1, column = 6, length = 19 }, }, highlights) - assert.are_same({"le plugins.nvim-surround"}, updated_lines) + assert.are_same({ "le plugins.nvim-surround" }, updated_lines) end) it("deletion", function()