From d8e775a418aff10cc9c79cf1f561fcefd43b6cdc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sindre=20T=2E=20Str=C3=B8m?= Date: Tue, 20 Dec 2022 15:53:19 +0100 Subject: [PATCH] feat: Support quoted arguments. (fixes #272) Everything between a pair of quotes will now be treated as part of a single argument. Example: :DiffviewFileHistory --grep="foo bar baz" -G"lorem ipsum dolor" :DiffviewOpen 'HEAD@{4 days ago}' * feat(arg-parser): Make the scanner more configurable. The scanner can now configurably, and correctly parse quoted args as well as EX command ranges. Provide more data in `CmdLineContext`s. * feat(arg-parser): Better processing of completion candidates. Do all completion candidate filtering and processing in the arg-parser. Properly support completion for |input()|. * refactor(file-history): Flag options into their own class. Better default behavior. Requires far less customization per flag option in order to get sensible behavior. --- lua/diffview/arg_parser.lua | 188 +++++++------ lua/diffview/init.lua | 30 +- .../scene/views/file_history/listeners.lua | 6 +- .../scene/views/file_history/option_panel.lua | 51 ++-- .../scene/views/file_history/render.lua | 20 +- lua/diffview/vcs/adapter.lua | 14 +- lua/diffview/vcs/adapters/git/init.lua | 258 ++++++------------ lua/diffview/vcs/flag_option.lua | 147 ++++++++++ lua/diffview/vcs/utils.lua | 12 - plugin/diffview.lua | 15 +- 10 files changed, 379 insertions(+), 362 deletions(-) create mode 100644 lua/diffview/vcs/flag_option.lua diff --git a/lua/diffview/arg_parser.lua b/lua/diffview/arg_parser.lua index c78fc71c..263d3a54 100644 --- a/lua/diffview/arg_parser.lua +++ b/lua/diffview/arg_parser.lua @@ -5,7 +5,7 @@ local utils = lazy.require("diffview.utils") ---@module "diffview.utils" local M = {} -local short_flag_pat = { "^%-(%a)=?(.*)", "^%+(%a)=?(.*)" } +local short_flag_pat = { "^[-+](%a)=?(.*)" } local long_flag_pat = { "^%-%-(%a[%a%d-]*)=?(.*)", "^%+%+(%a[%a%d-]*)=?(.*)" } ---@class ArgObject : diffview.Object @@ -231,43 +231,75 @@ function M.split_ex_range(arg) end ---@class CmdLineContext ----@field args string[] The complete list of arguments. ----@field arg_lead string ----@field argidx integer Index of the current argument. ----@field divideridx integer ----@field range string? Ex command range. ----@field between boolean The current position is between two arguments. - ----Scan an EX command string and split it into individual args. +---@field cmd_line string +---@field args string[] # The tokenized list of arguments. +---@field raw_args string[] # The unprocessed list of arguments. Contains syntax characters, such as quotes. +---@field arg_lead string # The leading part of the current argument. +---@field lead_quote string? # If present: the quote character used for the current argument. +---@field cur_pos integer # The cursor position in the command line. +---@field argidx integer # Index of the current argument. +---@field divideridx integer # The index of the end-of-options token. (default: math.huge) +---@field range string? # Ex command range. +---@field between boolean # The current position is between two arguments. + +---@class arg_parser.scan.Opt +---@field cur_pos integer # The current cursor position in the command line. +---@field allow_quoted boolean # Everything between a pair of quotes should be treated as part of a single argument. (default: true) +---@field allow_ex_range boolean # The command line may contain an EX command range. (default: false) + +---Tokenize a command line string. ---@param cmd_line string ----@param cur_pos number +---@param opt? arg_parser.scan.Opt ---@return CmdLineContext -function M.scan_ex_args(cmd_line, cur_pos) +function M.scan(cmd_line, opt) + opt = vim.tbl_extend("keep", opt or {}, { + cur_pos = #cmd_line + 1, + allow_quoted = true, + allow_ex_range = false, + }) --[[@as arg_parser.scan.Opt ]] + local args = {} + local raw_args = {} local arg_lead local divideridx = math.huge local argidx local between = false - local arg = "" + local cur_quote, lead_quote + local arg, raw_arg = "", "" local h, i = -1, 1 while i <= #cmd_line do local char = cmd_line:sub(i, i) - if not argidx and i > cur_pos then + if not argidx and i > opt.cur_pos then argidx = #args + 1 arg_lead = arg - if h < cur_pos then between = true end + lead_quote = cur_quote + if h < opt.cur_pos then between = true end end if char == "\\" then arg = arg .. char + raw_arg = raw_arg .. char if i < #cmd_line then i = i + 1 arg = arg .. cmd_line:sub(i, i) + raw_arg = raw_arg .. cmd_line:sub(i, i) end h = i + elseif cur_quote then + if char == cur_quote then + cur_quote = nil + else + arg = arg .. char + end + raw_arg = raw_arg .. char + h = i + elseif opt.allow_quoted and (char == [[']] or char == [["]]) then + cur_quote = char + raw_arg = raw_arg .. char + h = i elseif char:match("%s") then if arg ~= "" then table.insert(args, arg) @@ -275,11 +307,16 @@ function M.scan_ex_args(cmd_line, cur_pos) divideridx = #args end end + if raw_arg ~= "" then + table.insert(raw_args, raw_arg) + end arg = "" + raw_arg = "" -- Skip whitespace i = i + cmd_line:sub(i, -1):match("^%s+()") - 2 else arg = arg .. char + raw_arg = raw_arg .. char h = i end @@ -288,7 +325,11 @@ function M.scan_ex_args(cmd_line, cur_pos) if #arg > 0 then table.insert(args, arg) - if not arg_lead then arg_lead = arg end + table.insert(raw_args, raw_arg) + if not arg_lead then + arg_lead = arg + lead_quote = cur_quote + end if arg == "--" and cmd_line:sub(#cmd_line, #cmd_line) ~= "-" then divideridx = #args @@ -305,17 +346,26 @@ function M.scan_ex_args(cmd_line, cur_pos) local range if #args > 0 then - range, args[1] = M.split_ex_range(args[1]) + if opt.allow_ex_range then + range, args[1] = M.split_ex_range(args[1]) + _, raw_args[1] = M.split_ex_range(raw_args[1]) + end + if args[1] == "" then table.remove(args, 1) + table.remove(raw_args, 1) argidx = math.max(argidx - 1, 1) divideridx = math.max(divideridx - 1, 1) end end return { + cmd_line = cmd_line, args = args, + raw_args = raw_args, arg_lead = arg_lead or "", + lead_quote = lead_quote, + cur_pos = opt.cur_pos, argidx = argidx, divideridx = divideridx, range = range ~= "" and range or nil, @@ -323,92 +373,48 @@ function M.scan_ex_args(cmd_line, cur_pos) } end ----Scan a shell-like string and split it into individual args. This scanner ----understands quoted args. ----@param cmd_line string ----@param cur_pos number ----@return CmdLineContext -function M.scan_sh_args(cmd_line, cur_pos) - local args = {} - local arg_lead - local divideridx = math.huge - local argidx - local between = false - local cur_quote - local arg = "" - - local h, i = -1, 1 +---Filter completion candidates. +---@param arg_lead string +---@param candidates string[] +---@return string[] +function M.filter_candidates(arg_lead, candidates) + arg_lead, _ = vim.pesc(arg_lead) - while i <= #cmd_line do - local char = cmd_line:sub(i, i) + return vim.tbl_filter(function(item) + return item:match(arg_lead) + end, candidates) +end - if not argidx and i > cur_pos then - argidx = #args + 1 - arg_lead = arg - if h < cur_pos then between = true end - end +---Process completion candidates. +---@param candidates string[] +---@param ctx CmdLineContext +---@param input_cmp boolean? Completion for |input()|. +---@return string[] +function M.process_candidates(candidates, ctx, input_cmp) + if not candidates then return {} end - if char == "\\" then - if i < #cmd_line then - i = i + 1 - arg = arg .. cmd_line:sub(i, i) - end - h = i - elseif cur_quote then - if char == cur_quote then - cur_quote = nil - else - arg = arg .. char - end - h = i - elseif char == [[']] or char == [["]] then - cur_quote = char - h = i - elseif char:match("%s") then - if arg ~= "" then - table.insert(args, arg) - if arg == "--" and i - 1 < #cmd_line then - divideridx = #args - end - end - arg = "" - -- Skip whitespace - i = i + cmd_line:sub(i, -1):match("^%s+()") - 2 - else - arg = arg .. char - h = i - end + local cmd_lead = "" + local ex_lead = (ctx.lead_quote or "") .. ctx.arg_lead - i = i + 1 + if ctx.arg_lead and ctx.arg_lead:find("[^\\]%s") then + ex_lead = (ctx.lead_quote or "") .. ctx.arg_lead:match(".*[^\\]%s(.*)") end - if cur_quote then - error("The given command line contains a non-terminated string!") + if input_cmp then + cmd_lead = ctx.cmd_line:sub(1, ctx.cur_pos - #ex_lead) end - if #arg > 0 then - table.insert(args, arg) - if not arg_lead then arg_lead = arg end - - if arg == "--" and cmd_line:sub(#cmd_line, #cmd_line) ~= "-" then - divideridx = #args + local ret = vim.tbl_map(function(v) + if v:match("^" .. vim.pesc(ctx.arg_lead)) then + return cmd_lead .. ex_lead .. v:sub(#ctx.arg_lead + 1) + elseif input_cmp then + return cmd_lead .. v end - end - if not argidx then - argidx = #args - if cmd_line:sub(#cmd_line, #cmd_line):match("%s") then - argidx = argidx + 1 - end - end + return (ctx.lead_quote or "") .. v + end, candidates) - return { - args = args, - arg_lead = arg_lead or "", - argidx = argidx, - divideridx = divideridx, - between = between, - } + return M.filter_candidates(cmd_lead .. ex_lead, ret) end function M.ambiguous_bool(value, default, truthy, falsy) diff --git a/lua/diffview/init.lua b/lua/diffview/init.lua index 195d1d97..22194a89 100644 --- a/lua/diffview/init.lua +++ b/lua/diffview/init.lua @@ -105,16 +105,18 @@ function M.init() ]]) end -function M.open(...) - local view = lib.diffview_open(utils.tbl_pack(...)) +---@param args string[] +function M.open(args) + local view = lib.diffview_open(args) if view then view:open() end end ---@param range? { [1]: integer, [2]: integer } -function M.file_history(range, ...) - local view = lib.file_history(range, utils.tbl_pack(...)) +---@param args string[] +function M.file_history(range, args) + local view = lib.file_history(range, args) if view then view:open() end @@ -135,22 +137,12 @@ function M.close(tabpage) end end ----@param arg_lead string ----@param items string[] ----@return string[] -function M.filter_completion(arg_lead, items) - arg_lead, _ = vim.pesc(arg_lead) - return vim.tbl_filter(function(item) - return item:match(arg_lead) - end, items) -end - function M.completion(_, cmd_line, cur_pos) - local ctx = arg_parser.scan_ex_args(cmd_line, cur_pos) + local ctx = arg_parser.scan(cmd_line, { cur_pos = cur_pos, allow_ex_range = true }) local cmd = ctx.args[1] if cmd and M.completers[cmd] then - return M.filter_completion(ctx.arg_lead, M.completers[cmd](ctx)) + return arg_parser.process_candidates(M.completers[cmd](ctx), ctx) end end @@ -191,14 +183,14 @@ M.completers = { if ctx.argidx > ctx.divideridx then if adapter then - utils.vec_push(candidates, unpack(adapter:path_completion(ctx.arg_lead))) + utils.vec_push(candidates, unpack(adapter:path_candidates(ctx.arg_lead))) else utils.vec_push(candidates, unpack(vim.fn.getcompletion(ctx.arg_lead, "file", 0))) end elseif adapter then if not has_rev_arg and ctx.arg_lead:sub(1, 1) ~= "-" then utils.vec_push(candidates, unpack(adapter.comp.open:get_all_names())) - utils.vec_push(candidates, unpack(adapter:rev_completion(ctx.arg_lead, { + utils.vec_push(candidates, unpack(adapter:rev_candidates(ctx.arg_lead, { accept_range = true, }))) else @@ -221,7 +213,7 @@ M.completers = { adapter.comp.file_history:get_completion(ctx.arg_lead) or adapter.comp.file_history:get_all_names() )) - utils.vec_push(candidates, unpack(adapter:path_completion(ctx.arg_lead))) + utils.vec_push(candidates, unpack(adapter:path_candidates(ctx.arg_lead))) else utils.vec_push(candidates, unpack(vim.fn.getcompletion(ctx.arg_lead, "file", 0))) end diff --git a/lua/diffview/scene/views/file_history/listeners.lua b/lua/diffview/scene/views/file_history/listeners.lua index 7d7cb964..32f90c01 100644 --- a/lua/diffview/scene/views/file_history/listeners.lua +++ b/lua/diffview/scene/views/file_history/listeners.lua @@ -111,9 +111,9 @@ return function(view) end end elseif view.panel.option_panel:is_focused() then - local item = view.panel.option_panel:get_item_at_cursor() - if item then - view.panel.option_panel.emitter:emit("set_option", item[1]) + local option = view.panel.option_panel:get_item_at_cursor() + if option then + view.panel.option_panel.emitter:emit("set_option", option.key) end end end, diff --git a/lua/diffview/scene/views/file_history/option_panel.lua b/lua/diffview/scene/views/file_history/option_panel.lua index cd831b12..0392e0ee 100644 --- a/lua/diffview/scene/views/file_history/option_panel.lua +++ b/lua/diffview/scene/views/file_history/option_panel.lua @@ -68,15 +68,10 @@ function FHOptionPanel:init(parent) elseif self.flags.options[option_name] then local o = self.flags.options[option_name] - local prompt = utils.str_template(o.prompt_fmt, { - label = o.prompt_label and o.prompt_label .. " " or "", - flag_name = o[2] .. " ", - }) - prompt = prompt:sub(1, -2) if o.select then vim.ui.select(o.select, { - prompt = prompt, + prompt = o:render_prompt(), format_item = function(item) return item == "" and "" or item end, @@ -92,28 +87,23 @@ function FHOptionPanel:init(parent) else local completion = type(o.completion) == "function" and o.completion(self) or o.completion - utils.input(prompt, { + utils.input(o:render_prompt(), { default = o:render_default(cur_value), - completion = completion, + completion = type(completion) == "function" and function(_, cmd_line, cur_pos) + ---@cast completion fun(ctx: CmdLineContext): string[] + local ctx = arg_parser.scan(cmd_line, { cur_pos = cur_pos }) + return arg_parser.process_candidates(completion(ctx), ctx, true) + end or completion, callback = function(response) if response ~= "__INPUT_CANCELLED__" then - local values - - if response == nil then - values = { "" } - else - local ok, ctx = pcall(arg_parser.scan_sh_args, response, 1) - if not ok then - utils.err(ctx.args, true) - return - else - values = ctx.args - end - end + local values = response == nil and { "" } or arg_parser.scan(response).args if o.transform then - values = o.transform(values) - else + values = o:transform(values) + end + + if not o.expect_list then + ---@cast values string values = values[1] end @@ -178,11 +168,12 @@ function FHOptionPanel:setup_buffer() vim.keymap.set(mapping[1], mapping[2], mapping[3], opt) end - for group, _ in pairs(self.flags) do - for option_name, v in pairs(self.flags[group]) do + for _, group in pairs(self.flags) do + ---@cast group FlagOption[] + for option_name, v in pairs(group) do vim.keymap.set( "n", - v[1], + v.keymap, function() self.emitter:emit("set_option", option_name) end, @@ -196,10 +187,10 @@ function FHOptionPanel:update_components() local switch_schema = {} local option_schema = {} for _, option in ipairs(self.flags.switches) do - table.insert(switch_schema, { name = "switch", context = { option.key, option } }) + table.insert(switch_schema, { name = "switch", context = { option = option, }, }) end for _, option in ipairs(self.flags.options) do - table.insert(option_schema, { name = "option", context = { option.key, option } }) + table.insert(option_schema, { name = "option", context = { option = option }, }) end ---@type CompStruct @@ -218,7 +209,7 @@ function FHOptionPanel:update_components() end ---Get the file entry under the cursor. ----@return LogEntry|FileEntry|nil +---@return FlagOption? function FHOptionPanel:get_item_at_cursor() if not (self:is_open() and self:buf_loaded()) then return @@ -229,7 +220,7 @@ function FHOptionPanel:get_item_at_cursor() local comp = self.components.comp:get_comp_on_line(line) if comp and (comp.name == "switch" or comp.name == "option") then - return comp.context + return comp.context.option end end diff --git a/lua/diffview/scene/views/file_history/render.lua b/lua/diffview/scene/views/file_history/render.lua index a65a0b24..f4e2cd39 100644 --- a/lua/diffview/scene/views/file_history/render.lua +++ b/lua/diffview/scene/views/file_history/render.lua @@ -250,12 +250,12 @@ return { for _, item in ipairs(panel.components.switches.items) do ---@type RenderComponent comp = item.comp - local option = comp.context[2] - local enabled = log_options[comp.context[1]] + local option = comp.context.option --[[@as FlagOption ]] + local enabled = log_options[option.key] --[[@as boolean ]] - comp:add_text(" " .. option[1] .. " ", "DiffviewSecondary") - comp:add_text(option[3] .. " (", "DiffviewFilePanelFileName") - comp:add_text(option[2], enabled and "DiffviewFilePanelCounter" or "DiffviewDim1") + comp:add_text(" " .. option.keymap .. " ", "DiffviewSecondary") + comp:add_text(option.desc .. " (", "DiffviewFilePanelFileName") + comp:add_text(option.flag_name, enabled and "DiffviewFilePanelCounter" or "DiffviewDim1") comp:add_text(")", "DiffviewFilePanelFileName") comp:ln() end @@ -268,13 +268,13 @@ return { ---@type RenderComponent comp = item.comp ---@type FlagOption - local option = comp.context[2] - local value = log_options[comp.context[1]] or "" + local option = comp.context.option --[[@as FlagOption ]] + local value = log_options[option.key] or "" - comp:add_text(" " .. option[1] .. " ", "DiffviewSecondary") - comp:add_text(option[3] .. " (", "DiffviewFilePanelFileName") + comp:add_text(" " .. option.keymap .. " ", "DiffviewSecondary") + comp:add_text(option.desc .. " (", "DiffviewFilePanelFileName") - local empty, display_value = option:render_value(value) + local empty, display_value = option:render_display(value) comp:add_text(display_value, not empty and "DiffviewFilePanelCounter" or "DiffviewDim1") comp:add_text(")", "DiffviewFilePanelFileName") diff --git a/lua/diffview/vcs/adapter.lua b/lua/diffview/vcs/adapter.lua index 149b9537..96fcd718 100644 --- a/lua/diffview/vcs/adapter.lua +++ b/lua/diffview/vcs/adapter.lua @@ -90,7 +90,7 @@ end ---@param arg_lead string ---@param opt? RevCompletionSpec ---@return string[] -function VCSAdapter:rev_completion(arg_lead, opt) +function VCSAdapter:rev_candidates(arg_lead, opt) oop.abstract_stub() end @@ -385,16 +385,6 @@ function VCSAdapter:has_local(left, right) return left.type == RevType.LOCAL or right.type == RevType.LOCAL end ----@class FlagOption : string[] ----@field key string ----@field prompt_label string ----@field prompt_fmt string ----@field select string[] ----@field completion string|fun(panel: FHOptionPanel): function ----@field transform fun(values: string[]): any # Transform the values given by the user. ----@field render_value fun(option: FlagOption, value: string|string[]): boolean, string # Render the flag value in the panel. ----@field render_default fun(options: FlagOption, value: string|string[]): string # Render the default text for the input(). - VCSAdapter.flags = { ---@type FlagOption[] switches = {}, @@ -404,7 +394,7 @@ VCSAdapter.flags = { ---@param arg_lead string ---@return string[] -function VCSAdapter:path_completion(arg_lead) +function VCSAdapter:path_candidates(arg_lead) return vim.fn.getcompletion(arg_lead, "file", 0) end diff --git a/lua/diffview/vcs/adapters/git/init.lua b/lua/diffview/vcs/adapters/git/init.lua index 977c1e11..c23180eb 100644 --- a/lua/diffview/vcs/adapters/git/init.lua +++ b/lua/diffview/vcs/adapters/git/init.lua @@ -2,6 +2,7 @@ local Commit = require("diffview.vcs.adapters.git.commit").GitCommit local CountDownLatch = require("diffview.control").CountDownLatch local Diff2Hor = require("diffview.scene.layouts.diff_2_hor").Diff2Hor local FileEntry = require("diffview.scene.file_entry").FileEntry +local FlagOption = require("diffview.vcs.flag_option").FlagOption local GitRev = require("diffview.vcs.adapters.git.rev").GitRev local Job = require("plenary.job") local JobStatus = require("diffview.vcs.utils").JobStatus @@ -11,7 +12,6 @@ local VCSAdapter = require("diffview.vcs.adapter").VCSAdapter local arg_parser = require("diffview.arg_parser") local async = require("plenary.async") local config = require("diffview.config") -local diffview = require("diffview") local lazy = require("diffview.lazy") local logger = require("diffview.logger") local oop = require("diffview.oop") @@ -1656,88 +1656,52 @@ end GitAdapter.flags = { ---@type FlagOption[] switches = { - { "-f", "--follow", "Follow renames (only for single file)" }, - { "-p", "--first-parent", "Follow only the first parent upon seeing a merge commit" }, - { "-s", "--show-pulls", "Show merge commits the first introduced a change to a branch" }, - { "-R", "--reflog", "Include all reachable objects mentioned by reflogs" }, - { "-a", "--all", "Include all refs" }, - { "-m", "--merges", "List only merge commits" }, - { "-n", "--no-merges", "List no merge commits" }, - { "-r", "--reverse", "List commits in reverse order" }, + FlagOption("-f", "--follow", "Follow renames (only for single file)"), + FlagOption("-p", "--first-parent", "Follow only the first parent upon seeing a merge commit"), + FlagOption("-s", "--show-pulls", "Show merge commits the first introduced a change to a branch"), + FlagOption("-R", "--reflog", "Include all reachable objects mentioned by reflogs"), + FlagOption("-a", "--all", "Include all refs"), + FlagOption("-m", "--merges", "List only merge commits"), + FlagOption("-n", "--no-merges", "List no merge commits"), + FlagOption("-r", "--reverse", "List commits in reverse order"), }, ---@type FlagOption[] options = { - { - "=r", "++rev-range=", "Show only commits in the specified revision range", + FlagOption("=r", "++rev-range=", "Show only commits in the specified revision range", { ---@param panel FHOptionPanel completion = function(panel) - return function(arg_lead, _, _) - local view = panel.parent.parent - return view.adapter:rev_completion(arg_lead, { - accept_range = true, - }) + local view = panel.parent.parent + + ---@param ctx CmdLineContext + return function(ctx) + return view.adapter:rev_candidates(ctx.arg_lead, { accept_range = true }) end end, - }, - { - "=b", "++base=", "Set the base revision", + }), + FlagOption("=b", "++base=", "Set the base revision", { ---@param panel FHOptionPanel completion = function(panel) - return function(arg_lead, _, _) - local view = panel.parent.parent - return utils.vec_join("LOCAL", view.adapter:rev_completion(arg_lead, {})) + local view = panel.parent.parent + + ---@param ctx CmdLineContext + return function(ctx) + return utils.vec_join("LOCAL", view.adapter:rev_candidates(ctx.arg_lead)) end end, - }, - { "=n", "--max-count=", "Limit the number of commits" }, - { - "=L", "-L", "Trace line evolution", + }), + FlagOption("=n", "--max-count=", "Limit the number of commits" ), + FlagOption("=L", "-L", "Trace line evolution", { + expect_list = true, prompt_label = "(Accepts multiple values)", - prompt_fmt = "${label} ", + -- prompt_fmt = "${label} ", completion = function(_) - return function(arg_lead, _, _) - return M.line_trace_completion(arg_lead) - end - end, - transform = function(values) - return utils.tbl_fmap(values, function(v) - v = utils.str_match(v, { "^-L(.*)", ".*" }) - if v == "" then return nil end - return v - end) - end, - ---@param self FlagOption - ---@param value string|string[] - render_value = function(self, value) - if #value == 0 then - -- Just render the flag name - return true, self[2] - end - - -- Render a string of quoted args - return false, table.concat(vim.tbl_map(function(v) - if not v:match("^-L") then - -- Prepend the flag if it wasn't specified by the user. - v = "-L" .. v - end - return utils.str_quote(v, { only_if_whitespace = true }) - end, value), " ") - end, - render_default = function(_, value) - if #value == 0 then - -- Just render the flag name - return "-L" + ---@param ctx CmdLineContext + return function(ctx) + return M.line_trace_candidates(ctx.arg_lead) end - - -- Render a string of quoted args - return table.concat(vim.tbl_map(function(v) - v = select(1, v:gsub("\\", "\\\\")) - return utils.str_quote("-L" .. v, { only_if_whitespace = true }) - end, value), " ") end, - }, - { - "=d", "--diff-merges=", "Determines how merge commits are treated", + }), + FlagOption("=d", "--diff-merges=", "Determines how merge commits are treated", { select = { "", "off", @@ -1748,102 +1712,42 @@ GitAdapter.flags = { "dense-combined", "remerge", }, - }, - { "=a", "--author=", "List only commits from a given author", prompt_label = "(Extended regular expression)" }, - { "=g", "--grep=", "Filter commit messages", prompt_label = "(Extended regular expression)" }, - { "=G", "-G", "Search changes", prompt_label = "(Extended regular expression)" }, - { "=S", "-S", "Search occurrences", prompt_label = "(Extended regular expression)" }, - { - "--", "--", "Limit to files", + }), + FlagOption("=a", "--author=", "List only commits from a given author", { + prompt_label = "(Extended regular expression)" + }), + FlagOption("=g", "--grep=", "Filter commit messages", { + prompt_label = "(Extended regular expression)" + }), + FlagOption("=G", "-G", "Search changes", { + prompt_label = "(Extended regular expression)" + }), + FlagOption("=S", "-S", "Search occurrences", { + prompt_label = "(Extended regular expression)" + }), + FlagOption("--", "--", "Limit to files", { key = "path_args", + expect_list = true, prompt_label = "(Path arguments)", prompt_fmt = "${label}${flag_name} ", - transform = function(values) - return utils.tbl_fmap(values, function(v) - if v == "" then return nil end - return v - end) - end, - render_value = function(_, value) - if #value == 0 then - -- Just render the flag name - return true, "--" - end - - -- Render a string of quoted args - return false, table.concat(utils.vec_join( - "--", - vim.tbl_map(function(v) - v = v:gsub("\\", "\\\\") - return utils.str_quote(v, { only_if_whitespace = true }) - end, value) - ), " ") - end, + value_fmt = "${value}", + display_fmt = "${flag_name} ${values}", ---@param panel FHOptionPanel completion = function(panel) local view = panel.parent.parent - return function(_, cmd_line, cur_pos) - local ok, ctx = pcall(arg_parser.scan_sh_args, cmd_line, cur_pos) - - if ok then - local quoted = vim.tbl_map(function(v) - return utils.str_quote(v, { only_if_whitespace = true }) - end, ctx.args) - - return vim.tbl_map(function(v) - return table.concat(utils.vec_join( - utils.vec_slice(quoted, 1, ctx.argidx - 1), - utils.str_quote(v, { only_if_whitespace = true }) - ), " ") - end, view.adapter:path_completion(ctx.arg_lead)) - end + ---@param ctx CmdLineContext + return function(ctx) + return view.adapter:path_candidates(ctx.arg_lead) end end, - }, + }), }, } +-- Add reverse lookups for _, list in pairs(GitAdapter.flags) do for i, option in ipairs(list) do - option = vim.tbl_extend("keep", option, { - prompt_fmt = "${label}${flag_name}", - - key = option.key or utils.str_match(option[2], { - "^%-%-?([^=]+)=?", - "^%+%+?([^=]+)=?", - }):gsub("%-", "_"), - - ---@param self FlagOption - ---@param value string|string[] - render_value = function(self, value) - local quoted - - if type(value) == "table" then - quoted = table.concat(vim.tbl_map(function(v) - return self[2] .. utils.str_quote(v, { only_if_whitespace = true }) - end, value), " ") - else - quoted = self[2] .. utils.str_quote(value, { only_if_whitespace = true }) - end - - return value == "", quoted - end, - - ---@param value string|string[] - render_default = function(_, value) - if value == nil then - return "" - elseif type(value) == "table" then - return table.concat(vim.tbl_map(function(v) - v = select(1, v:gsub("\\", "\\\\")) - return utils.str_quote(v, { only_if_whitespace = true }) - end, value), " ") - end - return utils.str_quote(value, { only_if_whitespace = true }) - end, - }) - list[i] = option list[option.key] = option end @@ -1851,7 +1755,7 @@ end -- Completion -function GitAdapter:path_completion(arg_lead) +function GitAdapter:path_candidates(arg_lead) local magic, pattern = M.pathspec_split(arg_lead) return vim.tbl_map(function(v) @@ -1859,8 +1763,13 @@ function GitAdapter:path_completion(arg_lead) end, vim.fn.getcompletion(pattern, "file", 0)) end -function GitAdapter:rev_candidates() +---Get completion candidates for git revisions. +---@param arg_lead string +---@param opt? RevCompletionSpec +function GitAdapter:rev_candidates(arg_lead, opt) + opt = vim.tbl_extend("keep", opt or {}, { accept_range = false }) --[[@as RevCompletionSpec ]] logger.lvl(1).debug("[completion] Revision candidates requested.") + -- stylua: ignore start local targets = { "HEAD", "FETCH_HEAD", "ORIG_HEAD", "MERGE_HEAD", @@ -1884,38 +1793,31 @@ function GitAdapter:rev_candidates() { cwd = self.ctx.toplevel, silent = true } ) - return utils.vec_join(heads, revs, stashes) -end + local ret = utils.vec_join(heads, revs, stashes) ----Completion for git revisions. ----@param arg_lead string ----@param opt? RevCompletionSpec ----@return string[] -function GitAdapter:rev_completion(arg_lead, opt) - ---@type RevCompletionSpec - opt = vim.tbl_extend("keep", opt or {}, { accept_range = false }) - local candidates = self:rev_candidates() - local _, range_end = utils.str_match(arg_lead, { - "^(%.%.%.?)()$", - "^(%.%.%.?)()[^.]", - "[^.](%.%.%.?)()$", - "[^.](%.%.%.?)()[^.]", - }) + if opt.accept_range then + local _, range_end = utils.str_match(arg_lead, { + "^(%.%.%.?)()$", + "^(%.%.%.?)()[^.]", + "[^.](%.%.%.?)()$", + "[^.](%.%.%.?)()[^.]", + }) - if opt.accept_range and range_end then - local range_lead = arg_lead:sub(1, range_end - 1) - candidates = vim.tbl_map(function(v) - return range_lead .. v - end, candidates) + if range_end then + local range_lead = arg_lead:sub(1, range_end - 1) + ret = vim.tbl_map(function(v) + return range_lead .. v + end, ret) + end end - return diffview.filter_completion(arg_lead, candidates) + return ret end ---Completion for the git-log `-L` flag. ---@param arg_lead string ---@return string[]? -function M.line_trace_completion(arg_lead) +function M.line_trace_candidates(arg_lead) local range_end = arg_lead:match(".*:()") if not range_end then @@ -1943,10 +1845,10 @@ function GitAdapter:init_completion() end) self.comp.file_history:put({ "base" }, function(_, arg_lead) - return utils.vec_join("LOCAL", self:rev_completion(arg_lead)) + return utils.vec_join("LOCAL", self:rev_candidates(arg_lead)) end) self.comp.file_history:put({ "range" }, function(_, arg_lead) - return self:rev_completion(arg_lead, { accept_range = true }) + return self:rev_candidates(arg_lead, { accept_range = true }) end) self.comp.file_history:put({ "C" }, function(_, arg_lead) return vim.fn.getcompletion(arg_lead, "dir") @@ -1961,7 +1863,7 @@ function GitAdapter:init_completion() self.comp.file_history:put({ "--reverse" }) self.comp.file_history:put({ "--max-count", "-n" }, {}) self.comp.file_history:put({ "-L" }, function (_, arg_lead) - return M.line_trace_completion(arg_lead) + return M.line_trace_candidates(arg_lead) end) self.comp.file_history:put({ "--diff-merges" }, { "off", diff --git a/lua/diffview/vcs/flag_option.lua b/lua/diffview/vcs/flag_option.lua new file mode 100644 index 00000000..49f51e44 --- /dev/null +++ b/lua/diffview/vcs/flag_option.lua @@ -0,0 +1,147 @@ +local lazy = require("diffview.lazy") +local oop = require("diffview.oop") + +local utils = lazy.require("diffview.utils") ---@module "diffview.utils" + +local M = {} + +---@alias FlagOption.CompletionWrapper fun(parent: FHOptionPanel): fun(ctx: CmdLineContext): string[] + +---@class FlagOption +---@field flag_name string +---@field keymap string +---@field desc string +---@field key string +---@field expect_list boolean +---@field prompt_label string +---@field prompt_fmt string +---@field value_fmt string +---@field display_fmt string +---@field select? string[] +---@field completion? string|FlagOption.CompletionWrapper +local FlagOption = oop.create_class("FlagOption") + +---@class FlagOption.init.Opt +---@field flag_name string +---@field keymap string +---@field desc string +---@field key string +---@field expect_list boolean +---@field prompt_label string +---@field prompt_fmt string +---@field value_fmt string +---@field display_fmt string +---@field select? string[] +---@field completion? string|FlagOption.CompletionWrapper +---@field transform function +---@field prepare_values function +---@field render_prompt function +---@field render_value function +---@field render_display function +---@field render_default function + +---@param keymap string +---@param flag_name string +---@param desc string +---@param opt FlagOption.init.Opt +function FlagOption:init(keymap, flag_name, desc, opt) + opt = opt or {} + + self.keymap = keymap + self.flag_name = flag_name + self.desc = desc + self.key = opt.key or utils.str_match(flag_name, { + "^%-%-?([^=]+)=?", + "^%+%+?([^=]+)=?", + }):gsub("%-", "_") + self.select = opt.select + self.completion = opt.completion + self.expect_list = utils.sate(opt.expect_list, false) + self.prompt_label = opt.prompt_label or "" + self.prompt_fmt = opt.prompt_fmt or "${label}${flag_name}" + self.value_fmt = opt.value_fmt or "${flag_name}${value}" + self.display_fmt = opt.display_fmt or "${values}" + self.transform = opt.transform or self.transform + self.render_prompt = opt.render_prompt or self.render_prompt + self.render_value = opt.render_value or self.render_value + self.render_display = opt.render_display or self.render_display + self.render_default = opt.render_default or self.render_default +end + +---@param values any|any[] +---@return string[] +function FlagOption:prepare_values(values) + if values == nil then + return {} + elseif type(values) ~= "table" then + return { tostring(values) } + else + return vim.tbl_map(tostring, values) + end +end + +---Transform the values given by the user. +---@param values any|any[] +function FlagOption:transform(values) + return utils.tbl_fmap(self:prepare_values(values), function(v) + v = utils.str_match(v, { "^" .. vim.pesc(self.flag_name) .. "(.*)", ".*" }) + if v == "" then return nil end + return v + end) +end + +function FlagOption:render_prompt() + return utils.str_template(self.prompt_fmt, { + label = self.prompt_label and self.prompt_label .. " " or "", + flag_name = self.flag_name .. " ", + }):sub(1, -2) +end + +---Render a single option value +---@param value string +function FlagOption:render_value(value) + value = value:gsub("\\", "\\\\") + return utils.str_template(self.value_fmt, { + flag_name = self.flag_name, + value = utils.str_quote(value, { only_if_whitespace = true }), + }) +end + +---Render the displayed text for the panel. +---@param values any|any[] +---@return boolean empty +---@return string rendered_text +function FlagOption:render_display(values) + values = self:prepare_values(values) + if #values == 0 or (#values == 1 and values[1] == "") then + return true, self.flag_name + end + + local quoted = table.concat(vim.tbl_map(function(v) + return self:render_value(v) + end, values), " ") + + return false, utils.str_template(self.display_fmt, { + flag_name = self.flag_name, + values = quoted, + }) +end + +---Render the default text for |input()|. +---@param values any|any[] +function FlagOption:render_default(values) + values = self:prepare_values(values) + + local ret = vim.tbl_map(function(v) + return self:render_value(v) + end, values) + + if #ret > 0 then + ret[1] = ret[1]:match("^" .. vim.pesc(self.flag_name) .. "(.*)") or ret[1] + end + + return table.concat(ret, " ") +end + +M.FlagOption = FlagOption +return M diff --git a/lua/diffview/vcs/utils.lua b/lua/diffview/vcs/utils.lua index 3f799330..8266ad31 100644 --- a/lua/diffview/vcs/utils.lua +++ b/lua/diffview/vcs/utils.lua @@ -1,6 +1,5 @@ local CountDownLatch = require("diffview.control").CountDownLatch local FileDict = require("diffview.vcs.file_dict").FileDict -local FileEntry = require("diffview.scene.file_entry").FileEntry local Job = require("plenary.job") local RevType = require("diffview.vcs.rev").RevType local Semaphore = require("diffview.control").Semaphore @@ -216,17 +215,6 @@ M.diff_file_list = async.wrap(function(adapter, left, right, path_args, dv_opt, callback(nil, files) end, 7) - ----@param arg_lead string ----@param items string[] ----@return string[] -function M.filter_completion(arg_lead, items) - arg_lead, _ = vim.pesc(arg_lead) - return vim.tbl_filter(function(item) - return item:match(arg_lead) - end, items) -end - ---Restore a file to the state it was in, in a given commit / rev. If no commit ---is given, unstaged files are restored to the state in index, and staged files ---are restored to the state in HEAD. The file will also be written into the diff --git a/plugin/diffview.lua b/plugin/diffview.lua index 4f930d3d..1d356ba2 100644 --- a/plugin/diffview.lua +++ b/plugin/diffview.lua @@ -7,7 +7,8 @@ vim.g.diffview_nvim_loaded = 1 local lazy = require("diffview.lazy") ---@module "diffview" -local diffview = lazy.require("diffview") +local arg_parser = lazy.require("diffview.arg_parser") ---@module "diffview.arg_parser" +local diffview = lazy.require("diffview") ---@module "diffview" local api = vim.api local command = api.nvim_create_user_command @@ -19,18 +20,18 @@ local function completion(...) end -- Create commands -command("DiffviewOpen", function(state) - diffview.open(unpack(state.fargs)) +command("DiffviewOpen", function(ctx) + diffview.open(arg_parser.scan(ctx.args).args) end, { nargs = "*", complete = completion }) -command("DiffviewFileHistory", function(state) +command("DiffviewFileHistory", function(ctx) local range - if state.range > 0 then - range = { state.line1, state.line2 } + if ctx.range > 0 then + range = { ctx.line1, ctx.line2 } end - diffview.file_history(range, unpack(state.fargs)) + diffview.file_history(range, arg_parser.scan(ctx.args).args) end, { nargs = "*", complete = completion, range = true }) command("DiffviewClose", function()