Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 13 additions & 21 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,30 +1,32 @@
# 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!

![live-command.nvim demo video](https://user-images.githubusercontent.com/40792180/235201812-adc95327-65cc-4ae4-8c2e-804853dd0c02.gif)
<p><sub>Theme: <a href="https://github.com/folke/tokyonight.nvim">tokyonight.nvim</a></sub></p>

## :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.*",
Expand Down Expand Up @@ -84,7 +86,6 @@ require("live-command").setup {
change = "DiffChange",
},
},
debug = false,
}
```

Expand Down Expand Up @@ -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),
Expand Down
157 changes: 101 additions & 56 deletions lua/live-command/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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],
Expand Down Expand Up @@ -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
Expand All @@ -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)

Expand All @@ -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,
Expand Down Expand Up @@ -176,29 +177,35 @@ 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
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

Expand All @@ -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
Expand All @@ -220,20 +228,19 @@ 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

if not opts.line1 or not command.enable_highlighting then
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
Expand All @@ -247,15 +254,15 @@ 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)

set_lines(updated_lines)
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,
Expand All @@ -272,20 +279,20 @@ 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

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)
Expand Down Expand Up @@ -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.",
Expand All @@ -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
Loading