diff --git a/README.md b/README.md index 8c5405c..152a3fd 100644 --- a/README.md +++ b/README.md @@ -1,77 +1,82 @@ # live-command.nvim -![version](https://img.shields.io/badge/version-1.2.1-brightgreen) +![version](https://img.shields.io/badge/version-2.0.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! +> :exclamation: Version 2.0 has been released with breaking changes! Be sure to check out the [migration guide](./migrate_to_v2.md). + +Text editing in Neovim with immediate visual feedback: see the effects of any command on your buffer in real-time. Preview macros, the `:norm` command, and more! ![live-command.nvim demo video](https://user-images.githubusercontent.com/40792180/235201812-adc95327-65cc-4ae4-8c2e-804853dd0c02.gif)

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 was 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* they wish to preview. -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 addresses that limitation by offering a **simple API for creating previewable commands** +in Neovim. Just specify the command you want to preview and live-command will handle the rest. +This includes viewing **individual insertions, changes and deletions** as you type. ## 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: +## :inbox_tray: Installation +Install via your favorite package manager and call the `setup` function: + +
+ lazy.nvim + ```lua use { "smjonas/live-command.nvim", - -- live-command supports semantic versioning via tags - -- tag = "1.*", + -- live-command supports semantic versioning via Git tags + -- tag = "2.*", config = function() - require("live-command").setup { - commands = { - Norm = { cmd = "norm" }, - }, - } + require("live-command").setup() end, } ``` +
-## :gear: Usage and Customization -Each command you want to preview requires a name (must be upper-case) and the name of -an existing command that is run on each keypress. +
+ vim-plug -Here is a list of available settings: - -| Key | Type | Description -| ----------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------ -| cmd | string | The name of an existing command to preview. -| 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. -| 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. - -### Example -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). +```vim +Plug 'smjonas/live-command.nvim' +``` +In your `init.lua`, call the setup function: ```lua -local commands = { - Reg = { - cmd = "norm", - -- This will transform ":5Reg a" into ":norm 5@a" - args = function(opts) - return (opts.count == -1 and "" or opts.count) .. "@" .. opts.args - end, - range = "", - }, -} +require("live-command").setup() +``` +
+## :rocket: Getting started +### Basic Usage +The easiest way to use **live-command** is with the provided `:Preview` command. +For example, `:Preview delete` will show you a preview of deleting the current line. +You can also provide a count or a range to the command, such as `:'<,'>Preview norm A;`, which +shows the effect of appending a semicolon to every visually selected line. + +### Creating Previewable Commands +For a more convenient experience, **live-command** allows you to define custom previewable commands. +This can be done by passing a list of commands to the `setup` function. +For instance, to define a custom `:Norm` command that can be previewed, use the following: +```lua require("live-command").setup { - commands = commands, + commands = { + Norm = { cmd = "norm" }, + }, } ``` -\ -All of the following options can be set globally (for all created commands), or per command. -To change the default options globally, use the `defaults` table. The defaults are: +Each command you want to preview needs a name (which must be uppercase) and +an existing command to run on each keypress, specified via the `cmd` field. + +## :gear: Customization + +All of the following options can be set globally (affecting all custom commands), or per command. + +To change the default options globally, use the `defaults` table. The default settings are: ```lua require("live-command").setup { @@ -93,7 +98,7 @@ require("live-command").setup { Default: `true` -Whether highlights should be shown. If `false`, only text changes are shown. +Determines whether highlights should be shown. If `false`, only text changes are shown, without any highlights. --- @@ -101,9 +106,9 @@ Whether highlights should be shown. If `false`, only text changes are shown. Default: `true` -If `true`, differing lines will be compared in a second run of the diff algorithm. This -can result in multiple highlights per line. Otherwise, the whole line will be highlighted as -a single change highlight. +If `true`, differing lines will be compared in a second run of the diff algorithm +to identify smaller differences. This can result in multiple highlights per line. +If set to `false`, the whole line will be highlighted as a single change. --- @@ -111,10 +116,10 @@ a single change highlight. Default: `{ insertion = "DiffAdd", deletion = "DiffDelete", change = "DiffChange" }` -A list of highlight groups per edit type (insertion, deletion or change) used for highlighting buffer changes. -The table will be merged with the defaults so you can omit any keys that are the same as the default. -If a value is set to `false`, no highlights will be shown for that type. If `hl_groups.deletion` is `false`, -deletion edits will not be undone which is otherwise done to make the text changes visible. +A table mapping edit types (insertion, deletion or change) to highlight groups used for highlighting buffer changes. +This table is merged with the defaults, allowing you to omit any keys that match the default. +If a value is set to `false`, no highlights will be shown for that type. +If `hl_groups.deletion` is `false`, deletion edits will not be undone, so deleted text won't be highlighted. --- diff --git a/lua/live-command/cmd_executor.lua b/lua/live-command/cmd_executor.lua new file mode 100644 index 0000000..53df784 --- /dev/null +++ b/lua/live-command/cmd_executor.lua @@ -0,0 +1,86 @@ +local M = {} + +local differ = require("live-command.differ") +local highlighter = require("live-command.highlighter") +local logger = require("live-command.logger") + +---@type string +local latest_cmd + +local running = false + +local refetch_lines = true + +---@tyle string[] +local cached_lines + +---@type boolean +local prev_lazyredraw + +local setup = function(bufnr) + prev_lazyredraw = vim.o.lazyredraw + vim.o.lazyredraw = true + if refetch_lines then + cached_lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + refetch_lines = false + else + logger.trace("did not refetch for cmd " .. latest_cmd) + end + return cached_lines +end + +M.teardown = function(do_refetch_lines) + vim.o.lazyredraw = prev_lazyredraw + refetch_lines = do_refetch_lines + if vim.v.errmsg ~= "" then + logger.error(("An error occurred in the preview function:\n%s"):format(vim.inspect(vim.v.errmsg))) + end +end + +local execute_command = function(cmd, bufnr) + local old_buf_lines = setup(bufnr) + local visible_line_range = { vim.fn.line("w0"), vim.fn.line("w$") } + vim.api.nvim_buf_call(bufnr, function() + vim.cmd(cmd) + end) + -- M.teardown(false) + visible_line_range = { + math.max(visible_line_range[1], vim.fn.line("w0")), + math.max(visible_line_range[2], vim.fn.line("w$")), + } + local new_buf_lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + return old_buf_lines, new_buf_lines, visible_line_range +end + +---@param cmd string +---@param opts livecmd.Config +---@param bufnr number +---@param update_buffer_cb fun(bufnr:number,updated_buffer_lines:string[],highlights:livecmd.Highlight[]?) +M.submit_command = function(cmd, opts, bufnr, update_buffer_cb) + if cmd == latest_cmd then + return + end + latest_cmd = cmd + if not running then + running = true + local old_buf_lines, new_buf_lines, line_range = execute_command(cmd, bufnr) + if not opts.enable_highlighting then + update_buffer_cb(bufnr, new_buf_lines, nil) + running = false + return + end + local diff = differ.get_diff(old_buf_lines, new_buf_lines) + local highlights, updated_buf_lines = highlighter.get_highlights( + diff, + old_buf_lines, + new_buf_lines, + line_range, + opts.inline_highlighting, + opts.hl_groups.deletion ~= false + ) + update_buffer_cb(bufnr, updated_buf_lines, highlights) + running = false + end +end + +return M diff --git a/lua/live-command/config_validator.lua b/lua/live-command/config_validator.lua new file mode 100644 index 0000000..3d70ef8 --- /dev/null +++ b/lua/live-command/config_validator.lua @@ -0,0 +1,56 @@ +local M = {} + +local user_command = require("live-command.user_command") + +---@class livecmd.Config.HlGroups +---@field insertion string|false +---@field deletion string|false +---@field change string|false + +---@class livecmd.Config +---@field command_name string? +---@field enable_highlighting boolean? +---@field inline_highlighting boolean? +---@field hl_groups livecmd.Config.HlGroups? +---@field commands table + +local show_diagnostics_message = function(config) + local message = [[ +Version 2.0 of live-command.nvim has dropped support for the "args" and "range" keys in the command specification. +The following commands in your configuration are affected: %s. Please remove or modify them. +See the migration guide for more information: https://github.com/smjonas/live-command.nvim/blob/main/migrate_to_v2.md + ]] + local affected_cmds = {} + for cmd_name, cmd_spec in pairs(config.commands) do + if cmd_spec.args ~= nil or cmd_spec.range ~= nil then + table.insert(affected_cmds, '"' .. cmd_name .. '"') + end + end + local cmd_names = table.concat(affected_cmds, ", ") + local formatted_message = string.format(message, cmd_names) + vim.notify(formatted_message, vim.log.levels.INFO) +end + +---@param config livecmd.Config +M.validate_config = function(config) + vim.validate { + command_name = { config.command_name, "string" }, + enable_highlighting = { config.enable_highlighting, "boolean" }, + inline_highlighting = { config.inline_highlighting, "boolean" }, + hl_groups = { config.hl_groups, "table" }, + commands = { config.commands, "table" }, + } + for cmd_name, cmd_spec in pairs(config.commands) do + if cmd_spec.args ~= nil or cmd_spec.range ~= nil then + vim.notify( + '[live-command.nvim] Some unsupported features are used in your config. Please run ":LiveCommand diagnose" for details.', + vim.log.levels.WARN + ) + user_command.register_argument_handler("diagnose", function() + show_diagnostics_message(config) + end) + end + end +end + +return M diff --git a/lua/live-command/differ.lua b/lua/live-command/differ.lua new file mode 100644 index 0000000..64120d9 --- /dev/null +++ b/lua/live-command/differ.lua @@ -0,0 +1,9 @@ +local M = {} + +M.get_diff = function(old_lines, new_lines) + return vim.diff(table.concat(old_lines, "\n"), table.concat(new_lines, "\n"), { + result_type = "indices", + }) +end + +return M diff --git a/lua/live-command/highlighter.lua b/lua/live-command/highlighter.lua new file mode 100644 index 0000000..3050d57 --- /dev/null +++ b/lua/live-command/highlighter.lua @@ -0,0 +1,154 @@ +local M = {} + +---@class livecmd.Highlight +---@field line number +---@field column number +---@field length number +---@field kind string + +local logger = require("live-command.logger") + +-- 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) +end + +-- Inserts a newline character after each character of s and returns the table of characters. +local function splice(s) + local chars = {} + for i = 1, #s do + chars[2 * i - 1] = s:sub(i, i) + chars[2 * i] = "\n" + end + return table.concat(chars) +end + +local function add_inline_highlights(line, old_lines, new_lines, undo_deletions, highlights) + local line_a = splice(old_lines[line]) + local line_b = splice(new_lines[line]) + local line_diff = vim.diff(line_a, line_b, { result_type = "indices" }) + logger.trace(function() + return ("Changed lines (line %d):\nOriginal: '%s' (len=%d)\nUpdated: '%s' (len=%d)\n\nInline hunks: %s"):format( + line, + old_lines[line], + #old_lines[line], + new_lines[line], + #new_lines[line], + vim.inspect(line_diff) + ) + end) + + local defer + local col_offset = 0 + for _, line_hunk in ipairs(line_diff) do + local start_a, count_a, start_b, count_b = unpack(line_hunk) + local hunk_kind = (count_a == 0 and "insertion") or (count_b == 0 and "deletion") or "change" + + if hunk_kind ~= "deletion" or undo_deletions then + local highlight = { + hunk = line_hunk, + kind = hunk_kind, + line = line, + -- Add 1 because when count is zero, start_b / start_b is the position before the deletion + column = (hunk_kind == "deletion") and start_b + 1 or start_b, + length = (hunk_kind == "deletion") and count_a or count_b, + } + + if highlight.kind == "deletion" and undo_deletions then + local deleted_part = old_lines[line]:sub(start_a, start_a + count_a - 1) + -- Restore deleted characters + new_lines[line] = string_insert(new_lines[line], deleted_part, col_offset + start_b + 1) + defer = function() + col_offset = col_offset + #deleted_part + end + end + -- 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 + defer() + defer = nil + end + end + end +end + +--- @param old_lines string[] +--- @param new_lines string[] +--- @param line_range {start:number, end:number} +--- @param inline_highlighting boolean +--- @param undo_deletions boolean +--- @return livecmd.Highlight[], string[] +M.get_highlights = function(diff, old_lines, new_lines, line_range, inline_highlighting, undo_deletions) + local highlights = {} + for i, hunk in ipairs(diff) do + logger.trace(function() + return ("Hunk %d/%d: %s"):format(i, #diff, vim.inspect(hunk)) + end) + + local start_a, count_a, start_b, count_b = hunk[1], hunk[2], hunk[3], hunk[4] + local hunk_kind = (count_a < count_b and "insertion") or (count_a > count_b and "deletion") + if hunk_kind then + local start_line, end_line + if hunk_kind == "insertion" then + start_line = start_b + count_a + end_line = start_a + (count_b - count_a) + else + start_line = start_a + count_b + end_line = start_line + (count_a - count_b) - 1 + end + + logger.trace(function() + return ("Lines %d-%d:\nOriginal: %s\nUpdated: %s"):format( + start_line, + end_line, + vim.inspect(vim.list_slice(old_lines, start_line, end_line)), + vim.inspect(vim.list_slice(new_lines, start_line, end_line)) + ) + end) + + for line = start_line, end_line do + -- Outside of visible area, skip current or all hunks + if line > line_range[2] then + return highlights, new_lines + end + + if line >= line_range[1] then + if hunk_kind == "deletion" and undo_deletions then + -- Hunk was deleted: reinsert lines + table.insert(new_lines, line, old_lines[line]) + end + if new_lines[line] == "" then + -- Make empty lines visible + new_lines[line] = " " + end + table.insert(highlights, { kind = hunk_kind, line = line, column = 1, length = -1 }) + end + end + else + -- Change edit + for line = start_b, start_b + count_b - 1 do + -- Outside of visible area, skip current or all hunks + if line > line_range[2] then + return highlights, new_lines + end + + if line >= line_range[1] then + if inline_highlighting then + -- Get diff for each line in the hunk + add_inline_highlights(line, old_lines, new_lines, undo_deletions, highlights) + else + -- Use a single highlight for the whole line + table.insert(highlights, { kind = "change", line = line, column = 1, length = -1 }) + end + end + end + end + end + return highlights, new_lines +end + +return M diff --git a/lua/live-command/init.lua b/lua/live-command/init.lua index f9f983d..b7068e7 100644 --- a/lua/live-command/init.lua +++ b/lua/live-command/init.lua @@ -1,6 +1,8 @@ local M = {} -M.defaults = { +---@type livecmd.Config +M.default_config = { + command_name = "Preview", enable_highlighting = true, inline_highlighting = true, hl_groups = { @@ -8,245 +10,28 @@ M.defaults = { deletion = "DiffDelete", change = "DiffChange", }, + commands = {}, } +local cmd_executor local api = vim.api ----@type Logger -local logger +---@type livecmd.Config +local merged_config -local should_cache_lines = true -local cached_lines -local prev_lazyredraw +---@type string[]? +local received_lines --- 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) -end - --- Inserts a newline character after each character of s and returns the table of characters. -local function splice(s) - local chars = {} - for i = 1, #s do - chars[2 * i - 1] = s:sub(i, i) - chars[2 * i] = "\n" - end - return table.concat(chars) -end - -local unpack = table.unpack or unpack - -local function add_inline_highlights(line, cached_lns, updated_lines, undo_deletions, highlights) - 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" }) - 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], - #cached_lns[line], - updated_lines[line], - #updated_lines[line], - vim.inspect(line_diff) - ) - end) - - local defer - local col_offset = 0 - for _, line_hunk in ipairs(line_diff) do - local start_a, count_a, start_b, count_b = unpack(line_hunk) - local hunk_kind = (count_a == 0 and "insertion") or (count_b == 0 and "deletion") or "change" - - if hunk_kind ~= "deletion" or undo_deletions then - local highlight = { - hunk = line_hunk, - kind = hunk_kind, - line = line, - -- Add 1 because when count is zero, start_b / start_b is the position before the deletion - column = (hunk_kind == "deletion") and start_b + 1 or start_b, - length = (hunk_kind == "deletion") and count_a or count_b, - } - - if highlight.kind == "deletion" and undo_deletions then - local deleted_part = cached_lns[line]:sub(start_a, start_a + count_a - 1) - -- Restore deleted characters - updated_lines[line] = string_insert(updated_lines[line], deleted_part, col_offset + start_b + 1) - defer = function() - col_offset = col_offset + #deleted_part - end - end - -- 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 - defer() - defer = nil - end - end - end -end - --- Expose function to tests -M._add_inline_highlights = add_inline_highlights - -local function get_diff_highlights(cached_lns, updated_lines, line_range, opts) - local highlights = {} - -- Using the on_hunk callback and returning -1 to cancel causes an error so don't use that - local hunks = vim.diff(table.concat(cached_lns, "\n"), table.concat(updated_lines, "\n"), { - result_type = "indices", - }) - logger.trace(("Visible line range: %d-%d"):format(line_range[1], line_range[2])) - - for i, hunk in ipairs(hunks) do - logger.trace(function() - return ("Hunk %d/%d: %s"):format(i, #hunks, vim.inspect(hunk)) - end) - - local start_a, count_a, start_b, count_b = hunk[1], hunk[2], hunk[3], hunk[4] - local hunk_kind = (count_a < count_b and "insertion") or (count_a > count_b and "deletion") - if hunk_kind then - local start_line, end_line - if hunk_kind == "insertion" then - start_line = start_b + count_a - end_line = start_a + (count_b - count_a) - else - start_line = start_a + count_b - end_line = start_line + (count_a - count_b) - 1 - end - - logger.trace(function() - return ("Lines %d-%d:\nOriginal: %s\nUpdated: %s"):format( - start_line, - end_line, - vim.inspect(vim.list_slice(cached_lns, start_line, end_line)), - vim.inspect(vim.list_slice(updated_lines, start_line, end_line)) - ) - end) - - for line = start_line, end_line do - -- Outside of visible area, skip current or all hunks - if line > line_range[2] then - return highlights - end - - if line >= line_range[1] then - if hunk_kind == "deletion" and opts.undo_deletions then - -- Hunk was deleted: reinsert lines - table.insert(updated_lines, line, cached_lns[line]) - end - if updated_lines[line] == "" then - -- Make empty lines visible - updated_lines[line] = " " - end - table.insert(highlights, { kind = hunk_kind, line = line, column = 1, length = -1 }) - end - end - else - -- Change edit - for line = start_b, start_b + count_b - 1 do - -- Outside of visible area, skip current or all hunks - if line > line_range[2] then - return highlights - end - - if line >= line_range[1] then - if opts.inline_highlighting then - -- Get diff for each line in the hunk - add_inline_highlights(line, cached_lns, updated_lines, opts.undo_deletions, highlights) - else - -- Use a single highlight for the whole line - table.insert(highlights, { kind = "change", line = line, column = 1, length = -1 }) - end - end - end - end - end - return highlights -end - --- Expose functions to tests -M._preview_across_lines = get_diff_highlights - -local function run_buf_cmd(buf, cmd) - api.nvim_buf_call(buf, function() - logger.trace(function() - return ("Previewing command: %s (current line = %d)"):format(cmd, api.nvim_win_get_cursor(0)[1]) - end) - vim.cmd(cmd) - end) -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 = "" - local args = opts.cmd_args - local command = opts.command - - local bufnr = api.nvim_get_current_buf() - if should_cache_lines then - prev_lazyredraw = vim.o.lazyredraw - vim.o.lazyredraw = true - cached_lines = api.nvim_buf_get_lines(bufnr, 0, -1, false) - should_cache_lines = false - end - - -- Ignore any errors that occur while running the command. - -- This reduces noise when a plugin modifies vim.v.errmsg (whether accidentally or not). - local prev_errmsg = vim.v.errmsg - local visible_line_range = { vim.fn.line("w0"), vim.fn.line("w$") } - - if opts.line1 == opts.line2 then - run_buf_cmd(bufnr, ("%s %s"):format(command.cmd, args)) - else - run_buf_cmd(bufnr, ("%d,%d%s %s"):format(opts.line1, opts.line2, command.cmd, args)) - end - - vim.v.errmsg = prev_errmsg - -- Adjust range to account for potentially inserted lines / scroll - visible_line_range = { - math.max(visible_line_range[1], vim.fn.line("w0")), - math.max(visible_line_range[2], vim.fn.line("w$")), - } +---@type livecmd.Highlight[]? +local received_highlights - local updated_lines = api.nvim_buf_get_lines(bufnr, 0, -1, false) - local set_lines = function(lines) - -- TODO: is this worth optimizing? - api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) - if preview_buf then - 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 - logger.error("No line1 range provided") - end - return 2 - end - - -- An empty buffer is represented as { "" }, change it to {} - if not updated_lines[2] and updated_lines[1] == "" then - updated_lines = {} - end - - local highlights = get_diff_highlights(cached_lines, updated_lines, visible_line_range, { - undo_deletions = command.hl_groups["deletion"] ~= false, - inline_highlighting = command.inline_highlighting, - }) - logger.trace(function() - return "Highlights: " .. vim.inspect(highlights) - end) - - set_lines(updated_lines) +---@param bufnr number +---@param preview_ns number +---@param highlights livecmd.Highlight[] +---@param hl_groups table +local apply_highlights = function(bufnr, preview_ns, highlights, hl_groups) for _, hl in ipairs(highlights) do - local hl_group = command.hl_groups[hl.kind] + local hl_group = hl_groups[hl.kind] if hl_group ~= false then api.nvim_buf_add_highlight( bufnr, @@ -258,112 +43,109 @@ local function command_preview(opts, preview_ns, preview_buf) ) end end - return 2 end -local function restore_buffer_state() - vim.o.lazyredraw = prev_lazyredraw - should_cache_lines = true - if vim.v.errmsg ~= "" then - logger.error(("An error occurred in the preview function:\n%s"):format(vim.inspect(vim.v.errmsg))) +local refresh_cmd_preview = function() + local backspace = api.nvim_replace_termcodes("", true, false, true) + -- Hack to trigger command preview again after new buffer contents have been computed + if api.nvim_get_mode().mode == "c" then + api.nvim_feedkeys("a" .. backspace, "n", false) end end -local function execute_command(command) - logger.trace("Executing command: " .. command) - vim.cmd(command) - restore_buffer_state() +local on_receive_buffer = function(bufnr, lines, highlights) + received_lines = lines + received_highlights = highlights + refresh_cmd_preview() end -local create_user_commands = function(commands) - for name, command in pairs(commands) do - local args, range - 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) - or opts.range == 1 and tostring(opts.line1) - or "" - ) - execute_command(("%s%s %s"):format(range_string, command.cmd, args)) - end, { - nargs = "*", - range = true, - preview = function(opts, preview_ns, preview_buf) - opts.command = command - args = command.args - if args then - -- Update command args if provided - args = type(args) == "function" and args(opts) or args - else - args = opts.args - end - opts.cmd_args = args - range = command.range - return command_preview(opts, preview_ns, preview_buf) - end, - }) +---@param cmd string +M.preview_callback = function(cmd, preview_ns, preview_buf) + if received_lines then + api.nvim_buf_set_lines(0, 0, -1, false, received_lines) + received_lines = nil + end + if received_highlights then + apply_highlights(0, preview_ns, received_highlights, merged_config.hl_groups) + received_highlights = nil end + cmd_executor.submit_command(cmd, merged_config, 0, on_receive_buffer) + return 2 end -local validate_config = function(config) - local defaults = config.defaults - vim.validate { - defaults = { defaults, "table", true }, - commands = { config.commands, "table" }, - } - local possible_opts = { "enable_highlighting", "inline_highlighting", "hl_groups" } - for _, command in pairs(config.commands) do - for _, opt in ipairs(possible_opts) do - if command[opt] == nil and defaults and defaults[opt] ~= nil then - command[opt] = defaults[opt] - else - command[opt] = command[opt] or M.defaults[opt] - end - end - command.hl_groups = vim.tbl_deep_extend("force", {}, M.defaults.hl_groups, command.hl_groups) +M.get_range_string = function(cmd) + return (cmd.range == 2 and ("%s,%s"):format(cmd.line1, cmd.line2) or cmd.range == 1 and tostring(cmd.line1) or "") +end - vim.validate { - cmd = { command.cmd, "string" }, - args = { command.args, { "string", "function" }, true }, - range = { command.range, { "string" }, true }, - ["command.enable_highlighting"] = { command.enable_highlighting, "boolean", true }, - ["command.inline_highlighting"] = { command.inline_highlighting, "boolean", true }, - ["command.hl_groups"] = { command.hl_groups, "table", true }, - } - end +M._test_mode = false + +---@param preview_cmd_name string +M.create_preview_command = function(preview_cmd_name) + api.nvim_create_user_command(preview_cmd_name, function(cmd) + vim.cmd(cmd.args) + end, { + nargs = "*", + preview = function(opts, preview_ns, preview_buf) + local cmd_to_preview = opts.args + return M.preview_callback(cmd_to_preview, preview_ns, preview_buf) + end, + }) end -M.setup = function(user_config) - 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.", - vim.log.levels.ERROR - ) - return - end +---@class livecmd.CommandSpec +---@field cmd string + +---@param cmd_name string +---@param cmd_specs livecmd.CommandSpec +M.create_previewable_command = function(cmd_name, cmd_specs) + api.nvim_create_user_command(cmd_name, function(cmd) + vim.cmd(M.get_range_string(cmd) .. cmd_specs.cmd .. " " .. cmd.args) + end, { + nargs = "*", + range = true, + preview = function(cmd, preview_ns, preview_buf) + local cmd_to_preview = M.get_range_string(cmd) .. cmd_specs.cmd .. " " .. cmd.args + return M.preview_callback(cmd_to_preview, preview_ns, preview_buf) + end, + }) +end - 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") +---@param cmd_specs table +local create_previewable_commands = function(cmd_specs) + for cmd_name, cmd_spec in pairs(cmd_specs) do + M.create_previewable_command(cmd_name, cmd_spec) + end +end +local create_autocmds = function() local id = 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. api.nvim_create_autocmd({ "CmdLineLeave" }, { group = id, -- Schedule wrap to run after a potential command execution callback = vim.schedule_wrap(function() - restore_buffer_state() + cmd_executor.teardown(true) end), }) end ----@param logger_ Logger -M._set_logger = function(logger_) - logger = logger_ +---@param user_config livecmd.Config? +M.setup = function(user_config) + if vim.fn.has("nvim-0.8.0") ~= 1 then + vim.notify( + "[live-command] This plugin requires at least Neovim 0.8. Please upgrade to a more recent version of Neovim.", + vim.log.levels.ERROR + ) + return + end + cmd_executor = require("live-command.cmd_executor") + merged_config = vim.tbl_deep_extend("force", M.default_config, user_config or {}) + require("live-command.config_validator").validate_config(merged_config) + M.create_preview_command(merged_config.command_name) + create_previewable_commands(merged_config.commands) + create_autocmds() end -M.version = "1.2.1" +M.version = "2.0.0" return M diff --git a/lua/live-command/logger.lua b/lua/live-command/logger.lua index 448a2c7..dc972cd 100644 --- a/lua/live-command/logger.lua +++ b/lua/live-command/logger.lua @@ -1,6 +1,8 @@ ---@class Logger local M = {} +local user_command = require("live-command.user_command") + ---@type Log[] local logs = {} @@ -20,7 +22,7 @@ M.error = function(msg) table.insert(logs, { msg = msg, level = vim.log.levels.ERROR }) end -vim.api.nvim_create_user_command("LiveCommandLog", function() +local show_log = function() local msgs = {} for i, log in ipairs(logs) do local level = "" @@ -31,8 +33,9 @@ vim.api.nvim_create_user_command("LiveCommandLog", function() end msgs[i] = level .. (type(log.msg) == "function" and log.msg() or log.msg) end - vim.notify(table.concat(msgs, "\n")) -end, { nargs = 0 }) +end + +user_command.register_argument_handler("log", show_log) return M diff --git a/lua/live-command/user_command.lua b/lua/live-command/user_command.lua new file mode 100644 index 0000000..c0d9482 --- /dev/null +++ b/lua/live-command/user_command.lua @@ -0,0 +1,34 @@ +local M = {} + +local handlers = {} + +---@param arg string +---@param handler fun(string) +M.register_argument_handler = function(arg, handler) + handlers[arg] = handler +end + +local create_user_command = function() + vim.api.nvim_create_user_command("LiveCommand", function(selected) + local arg = selected.fargs[1] + local handler = handlers[arg] + if handler then + handler(arg) + else + vim.notify("[live-command] Unknown argument " .. arg) + end + end, { + nargs = 1, + complete = function(arglead, _, _) + local args = vim.tbl_keys(handlers) + -- Only complete arguments that start with arglead + return vim.tbl_filter(function(arg) + return arg:match("^" .. arglead) + end, args) + end, + }) +end + +create_user_command() + +return M diff --git a/lua/live_command/init.lua b/lua/live_command/init.lua index e3708aa..caa5b4b 100644 --- a/lua/live_command/init.lua +++ b/lua/live_command/init.lua @@ -1,11 +1,7 @@ local M = {} M.setup = function(user_config) - vim.notify( - "[live-command]: The Lua module live_command has been renamed to live-command to match the repo name.\n" - .. 'Please change require("live_command") to require("live-command").', - vim.log.levels.WARN - ) + vim.notify('[live-command]: Please change require("live_command") to require("live-command").', vim.log.levels.WARN) require("live-command").setup(user_config) end diff --git a/migrate_to_v2.md b/migrate_to_v2.md new file mode 100644 index 0000000..0374bae --- /dev/null +++ b/migrate_to_v2.md @@ -0,0 +1,60 @@ +# Migration to v2.0 +This is a guide for users looking to migrate to version `2.0` of `live-command`. +If you'd prefer to avoid breaking changes, you can pin the plugin to tag [`1.x`](https://github.com/smjonas/live-command.nvim/releases/tag/1.x) tag. + +## What's new in version 2.0? +Version 2.0 is a complete rewrite, aimed at improving maintainability and future extensibility. +It introduces a simplified user-facing API, alongside improvements to the architecture of the plugin and the addition of a new `:Preview` command. + +**Breaking changes**: +- Custom command specifications now only consist of a `cmd` value (a string). The `args` + and `range` fields have been removed. See [next section](#how-can-i-migrate-from-older-versions) for details. + +**New feature**: +- A new generic `:Preview` command allows you to preview any command without needing to + define it in your configuration. This is useful for testing `live-command`'s capabilities + or for previewing commands you use infrequently, where creating a separate user command doesn't + seem necessary. The command does not accept a range or count. Example: `:Preview '<,'>norm daw` + previews the deletion of the first word in the selected lines. + +## How can I migrate from older versions? +In versions `1.x`, the following example demonstrated how to preview the results of a macro: +```lua +local commands = { + Reg = { + cmd = "norm", + -- This will transform ":5Reg a" into ":norm 5@a" + args = function(opts) + return (opts.count == -1 and "" or opts.count) .. "@" .. opts.args + end, + range = "", + }, +} +``` +In version `2.0`, you have two options: +1. Define a command `Norm = { cmd = "norm" }` and use it as `:Norm @` (e.g., `:Norm 5@a` to apply macro stored in register `a` five times). +2. Alternatively, define a custom `:Reg` user command that bevaves like the old version: + +
+ View code + +```lua +-- Transforms ":5Reg a" into ":norm 5@a" +local function get_command_string(cmd) + local get_range_string = require("live-command").get_range_string + local args = (cmd.count == -1 and "" or cmd.count) .. "@" .. cmd.args + return get_range_string(cmd) .. "norm " .. args +end + +vim.api.nvim_create_user_command("Reg", function(cmd) + vim.cmd(get_command_string(cmd)) +end, { + nargs = "?", + range = true, + preview = function(cmd, preview_ns, preview_buf) + local cmd_to_preview = get_command_string(cmd) + return require("live-command").preview_callback(cmd_to_preview, preview_ns, preview_buf) + end +}) +``` +
diff --git a/tests/e2e_spec.lua b/tests/e2e_spec.lua new file mode 100644 index 0000000..9f2bd1b --- /dev/null +++ b/tests/e2e_spec.lua @@ -0,0 +1,68 @@ +local live_command = require("live-command") +local api = vim.api + +local bufnr + +local get_lines = function() + return api.nvim_buf_get_lines(bufnr, 0, -1, false) +end + +before_each(function() + bufnr = api.nvim_create_buf(false, true) + api.nvim_buf_set_lines(bufnr, 0, -1, false, { "First line", "Second line" }) + api.nvim_set_current_buf(bufnr) +end) + +describe("create_preview_command works for", function() + it("norm command", function() + live_command.create_previewable_command("Norm", { cmd = "norm" }) + vim.cmd("Norm daw") + assert.are_same({ "line", "Second line" }, get_lines()) + end) + + it("norm command with count", function() + live_command.create_previewable_command("Norm", { cmd = "norm" }) + vim.cmd("2Norm daw") + assert.are_same({ "First line", "line" }, get_lines()) + end) + + it("norm command with range", function() + live_command.create_previewable_command("Norm", { cmd = "norm" }) + vim.cmd("1,2Norm daw") + assert.are_same({ "line", "line" }, get_lines()) + end) + + it("g command", function() + live_command.create_previewable_command("G", { cmd = "g" }) + vim.cmd("G/Second/d") + assert.are_same({ "First line" }, get_lines()) + end) + + it("#kek command spec in config", function() + vim.api.nvim_set_current_buf(bufnr) + live_command.setup { + commands = { + ABC = { cmd = "norm" }, + }, + } + vim.cmd("ABC daw") + assert.are_same({ "line", "Second line" }, get_lines()) + end) +end) + +describe(":Preview works for", function() + it("norm command", function() + vim.cmd("Preview norm daw") + assert.are_same({ "line", "Second line" }, get_lines()) + end) + + it("norm command with count", function() + vim.cmd("Preview 2norm daw") + assert.are_same({ "First line", "line" }, get_lines()) + end) + + it("g command", function() + vim.cmd("Preview g/Second/d") + assert.are_same({ "First line" }, get_lines()) + end) +end) diff --git a/tests/highlighter_spec.lua b/tests/highlighter_spec.lua new file mode 100644 index 0000000..e6211f7 --- /dev/null +++ b/tests/highlighter_spec.lua @@ -0,0 +1,167 @@ +local highlighter = require("live-command.highlighter") + +describe("inline_highlights", function() + local function compute_diff(old_lines, new_lines) + return vim.diff(old_lines[1], new_lines[1], { result_type = "indices" }) + end + + -- Checks for the case when the end of the line was unchanged + it("single insertion", function() + local old_lines = { "word" } + local new_lines = { "new word" } + + local diff = compute_diff(old_lines, new_lines) + local line_range = { 1, #new_lines } + + local inline_highlighting = true + local undo_deletions = true + + local highlights, updated_lines = + highlighter.get_highlights(diff, old_lines, new_lines, line_range, inline_highlighting, undo_deletions) + + assert.are_same({ + { kind = "insertion", line = 1, column = 1, length = 4 }, + }, highlights) + assert.are_same({ "new word" }, updated_lines) + end) + + it("insertions", function() + local old_lines = { "word" } + local new_lines = { "new word new" } + + local diff = compute_diff(old_lines, new_lines) + local line_range = { 1, #new_lines } + + local inline_highlighting = true + local undo_deletions = true + + local highlights, updated_lines = + highlighter.get_highlights(diff, old_lines, new_lines, line_range, inline_highlighting, undo_deletions) + + assert.are_same({ + { kind = "insertion", line = 1, column = 1, length = 4 }, + { kind = "insertion", line = 1, column = 9, length = 4 }, + }, highlights) + assert.are_same({ "new word new" }, updated_lines) + end) + + it("insertions + deletion", function() + local old_lines = { "a test" } + local new_lines = { "test1" } + + local diff = compute_diff(old_lines, new_lines) + local line_range = { 1, #new_lines } + + local inline_highlighting = true + local undo_deletions = true + + local highlights, updated_lines = + highlighter.get_highlights(diff, old_lines, new_lines, line_range, inline_highlighting, undo_deletions) + + assert.are_same({ + { kind = "deletion", line = 1, column = 1, length = 2 }, + { kind = "insertion", line = 1, column = 7, length = 1 }, + }, highlights) + assert.are_same({ "a test1" }, updated_lines) + end) + + it("change 1", function() + local old_lines = { " table.insert" } + local new_lines = { " x.insert" } + + local diff = compute_diff(old_lines, new_lines) + local line_range = { 1, #new_lines } + + local inline_highlighting = true + local undo_deletions = true + + local highlights, updated_lines = + highlighter.get_highlights(diff, old_lines, new_lines, line_range, inline_highlighting, undo_deletions) + + assert.are_same({ + { kind = "change", line = 1, column = 7, length = 1 }, + }, highlights) + assert.are_same({ " x.insert" }, updated_lines) + end) + + it("change 2", function() + local old_lines = { "config = function()" } + local new_lines = { "test = Function()" } + + local diff = compute_diff(old_lines, new_lines) + local line_range = { 1, #new_lines } + + local inline_highlighting = true + local undo_deletions = true + + local highlights, updated_lines = + highlighter.get_highlights(diff, old_lines, new_lines, line_range, inline_highlighting, undo_deletions) + + assert.are_same({ + { kind = "change", line = 1, column = 1, length = 4 }, + { kind = "change", line = 1, column = 8, length = 1 }, + }, highlights) + assert.are_same({ "test = Function()" }, updated_lines) + end) + + it("change should not use negative column values", function() + local old_lines = { "line" } + local new_lines = { "tes" } + + local diff = compute_diff(old_lines, new_lines) + local line_range = { 1, #new_lines } + + local inline_highlighting = true + local undo_deletions = true + + local highlights, updated_lines = + highlighter.get_highlights(diff, old_lines, new_lines, line_range, inline_highlighting, undo_deletions) + + assert.are_same({ + { kind = "change", line = 1, column = 1, length = 1 }, + { kind = "insertion", line = 1, column = 3, length = 1 }, + }, highlights) + assert.are_same({ "tes" }, updated_lines) + end) + + -- TODO: create the same test but when undo_deletions = false + it("change + deletion", function() + local old_lines = { "-- require plugins.nvim-surround" } + local new_lines = { "lel" } + + local diff = compute_diff(old_lines, new_lines) + local line_range = { 1, #new_lines } + + local inline_highlighting = true + local undo_deletions = true + + local highlights, updated_lines = + highlighter.get_highlights(diff, old_lines, new_lines, line_range, inline_highlighting, undo_deletions) + + assert.are_same({ + { kind = "change", line = 1, column = 1, length = 1 }, + { 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) + end) + + it("deletion", function() + local old_lines = { "local tests" } + local new_lines = { "local s" } + + local diff = compute_diff(old_lines, new_lines) + local line_range = { 1, #new_lines } + + local inline_highlighting = true + local undo_deletions = true + + local highlights, updated_lines = + highlighter.get_highlights(diff, old_lines, new_lines, line_range, inline_highlighting, undo_deletions) + + assert.are_same({ + { kind = "deletion", line = 1, column = 7, length = 4 }, + }, highlights) + assert.are_same({ "local tests" }, updated_lines) + end) +end) diff --git a/tests/init_spec.lua b/tests/init_spec.lua deleted file mode 100644 index 123f2a4..0000000 --- a/tests/init_spec.lua +++ /dev/null @@ -1,100 +0,0 @@ -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 = {} - local updated_lines = { "new word" } - live_command._add_inline_highlights(1, { "word" }, updated_lines, true, highlights) - - assert.are_same({ - { kind = "insertion", line = 1, column = 1, length = 4 }, - }, highlights) - assert.are_same({ "new word" }, updated_lines) - end) - - it("insertions", function() - local highlights = {} - local updated_lines = { "new word new" } - live_command._add_inline_highlights(1, { "word" }, updated_lines, true, highlights) - - assert.are_same({ - { kind = "insertion", line = 1, column = 1, length = 4 }, - { kind = "insertion", line = 1, column = 9, length = 4 }, - }, highlights) - assert.are_same({ "new word new" }, updated_lines) - end) - - it("insertions + deletion", function() - local highlights = {} - local updated_lines = { "test1" } - live_command._add_inline_highlights(1, { "a test" }, updated_lines, true, highlights) - - assert.are_same({ - { kind = "deletion", line = 1, column = 1, length = 2 }, - { kind = "insertion", line = 1, column = 7, length = 1 }, - }, highlights) - assert.are_same({ "a test1" }, updated_lines) - end) - - it("change 1", function() - local highlights = {} - local updated_lines = { " x.insert" } - live_command._add_inline_highlights(1, { " table.insert" }, updated_lines, true, highlights) - - assert.are_same({ - { kind = "change", line = 1, column = 7, length = 1 }, - }, highlights) - assert.are_same({ " x.insert" }, updated_lines) - end) - - it("change 2", function() - local highlights = {} - local updated_lines = { "test = Function()" } - live_command._add_inline_highlights(1, { "config = function()" }, updated_lines, true, highlights) - assert.are_same({ - { kind = "change", line = 1, column = 1, length = 4 }, - { kind = "change", line = 1, column = 8, length = 1 }, - }, highlights) - assert.are_same({ "test = Function()" }, updated_lines) - end) - - it("change should not use negative column values", function() - local highlights = {} - local updated_lines = { "tes" } - live_command._add_inline_highlights(1, { "line" }, updated_lines, true, highlights) - - assert.are_same({ - { kind = "change", line = 1, column = 1, length = 1 }, - { kind = "insertion", line = 1, column = 3, length = 1 }, - }, highlights) - assert.are_same({ "tes" }, updated_lines) - end) - - -- TODO: create the same test but when undo_deletions = false - it("change + deletion", function() - local highlights = {} - local updated_lines = { "lel" } - live_command._add_inline_highlights(1, { "-- require plugins.nvim-surround" }, updated_lines, true, highlights) - assert.are_same({ - { kind = "change", line = 1, column = 1, length = 1 }, - { 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) - end) - - it("deletion", function() - local highlights = {} - local updated_lines = { "local s" } - live_command._add_inline_highlights(1, { "local tests" }, updated_lines, true, highlights) - assert.are_same({ - { kind = "deletion", line = 1, column = 7, length = 4 }, - }, highlights) - assert.are_same({ "local tests" }, updated_lines) - end) -end)