From 96368e13e9b1aaacc570e4825b8787307f0d05e1 Mon Sep 17 00:00:00 2001
From: Steven Arcangeli <stevearc@stevearc.com>
Date: Mon, 10 Jun 2024 16:44:59 -0500
Subject: [PATCH] feat: keymap actions can be parameterized

---
 README.md               |   8 +-
 doc/oil.txt             | 116 ++++++++++++++++++---------
 lua/oil/actions.lua     | 171 ++++++++++++++++++++++++++++++++++++----
 lua/oil/config.lua      |   8 +-
 lua/oil/keymap_util.lua |  24 +++++-
 scripts/generate.py     |  60 +++++++++++++-
 6 files changed, 321 insertions(+), 66 deletions(-)

diff --git a/README.md b/README.md
index d96ed189..fa310590 100644
--- a/README.md
+++ b/README.md
@@ -184,16 +184,16 @@ require("oil").setup({
   keymaps = {
     ["g?"] = "actions.show_help",
     ["<CR>"] = "actions.select",
-    ["<C-s>"] = "actions.select_vsplit",
-    ["<C-h>"] = "actions.select_split",
-    ["<C-t>"] = "actions.select_tab",
+    ["<C-s>"] = { "actions.select_split", opts = { vertical = true } },
+    ["<C-h>"] = { "actions.select_split", opts = { horizontal = true } },
+    ["<C-t>"] = { "actions.select_split", opts = { tab = true } },
     ["<C-p>"] = "actions.preview",
     ["<C-c>"] = "actions.close",
     ["<C-l>"] = "actions.refresh",
     ["-"] = "actions.parent",
     ["_"] = "actions.open_cwd",
     ["`"] = "actions.cd",
-    ["~"] = "actions.tcd",
+    ["~"] = { "actions.cd", opts = { scope = "tab" } },
     ["gs"] = "actions.change_sort",
     ["gx"] = "actions.open_external",
     ["g."] = "actions.toggle_hidden",
diff --git a/doc/oil.txt b/doc/oil.txt
index 056dc524..548c7396 100644
--- a/doc/oil.txt
+++ b/doc/oil.txt
@@ -75,16 +75,16 @@ CONFIG                                                                *oil-confi
       keymaps = {
         ["g?"] = "actions.show_help",
         ["<CR>"] = "actions.select",
-        ["<C-s>"] = "actions.select_vsplit",
-        ["<C-h>"] = "actions.select_split",
-        ["<C-t>"] = "actions.select_tab",
+        ["<C-s>"] = { "actions.select_split", opts = { vertical = true } },
+        ["<C-h>"] = { "actions.select_split", opts = { horizontal = true } },
+        ["<C-t>"] = { "actions.select_split", opts = { tab = true } },
         ["<C-p>"] = "actions.preview",
         ["<C-c>"] = "actions.close",
         ["<C-l>"] = "actions.refresh",
         ["-"] = "actions.parent",
         ["_"] = "actions.open_cwd",
         ["`"] = "actions.cd",
-        ["~"] = "actions.tcd",
+        ["~"] = { "actions.cd", opts = { scope = "tab" } },
         ["gs"] = "actions.change_sort",
         ["gx"] = "actions.open_external",
         ["g."] = "actions.toggle_hidden",
@@ -435,38 +435,71 @@ birthtime                                                       *column-birthtim
 --------------------------------------------------------------------------------
 ACTIONS                                                              *oil-actions*
 
-These are actions that can be used in the `keymaps` section of config options.
-You can also call them directly with
-`require("oil.actions").action_name.callback()`
-
-add_to_loclist                                            *actions.add_to_loclist*
-    Adds files in the current oil directory to the location list, keeping the
-    previous entries.
+The `keymaps` option in `oil.setup` allow you to create mappings using all the same parameters as |vim.keymap.set|.
+>lua
+    keymaps = {
+        -- Mappings can be a string
+        ["~"] = "<cmd>edit $HOME<CR>",
+        -- Mappings can be a function
+        ["gd"] = function()
+            require("oil").set_columns({ "icon", "permissions", "size", "mtime" })
+        end,
+        -- You can pass additional opts to vim.keymap.set by using
+        -- a table with the mapping as the first element.
+        ["<leader>ff"] = {
+            function()
+                require("telescope.builtin").find_files({
+                    cwd = require("oil").get_current_dir()
+                })
+            end,
+            mode = "n",
+            nowait = true,
+            desc = "Find files in the current directory"
+        },
+        -- Mappings that are a string starting with "actions." will be
+        -- one of the built-in actions, documented below.
+        ["`"] = "actions.tcd",
+        -- Some actions have parameters. These are passed in via the `opts` key.
+        ["<leader>:"] = {
+            "actions.open_cmdline",
+            opts = {
+                shorten_path = true,
+                modify = ":h",
+            },
+            desc = "Open the command line with the current directory as an argument",
+        },
+    }
 
-add_to_qflist                                              *actions.add_to_qflist*
-    Adds files in the current oil directory to the quickfix list, keeping the
-    previous entries.
+Below are the actions that can be used in the `keymaps` section of config
+options. You can refer to them as strings (e.g. "actions.<action_name>") or you
+can use the functions directly with
+`require("oil.actions").action_name.callback()`
 
 cd                                                                    *actions.cd*
     :cd to the current oil directory
 
+    Parameters:
+      {scope}  `nil|"tab"|"win"` Scope of the directory change (e.g. use |:tcd|
+               or |:lcd|)
+      {silent} `boolean` Do not show a message when changing directories
+
 change_sort                                                  *actions.change_sort*
     Change the sort order
 
+    Parameters:
+      {sort} `oil.SortSpec[]` List of columns plus direction (see
+             |oil.set_sort|) instead of interactive selection
+
 close                                                              *actions.close*
     Close oil and restore original buffer
 
-copy_entry_filename                                  *actions.copy_entry_filename*
-    Yank the filename of the entry under the cursor to a register
-
-copy_entry_path                                          *actions.copy_entry_path*
-    Yank the filepath of the entry under the cursor to a register
-
 open_cmdline                                                *actions.open_cmdline*
     Open vim cmdline with current entry as an argument
 
-open_cmdline_dir                                        *actions.open_cmdline_dir*
-    Open vim cmdline with current directory as an argument
+    Parameters:
+      {modify}       `string` Modify the path with |fnamemodify()| using this as
+                     the mods argument
+      {shorten_path} `boolean` Use relative paths when possible
 
 open_cwd                                                        *actions.open_cwd*
     Open oil in Neovim's current working directory
@@ -493,38 +526,47 @@ preview_scroll_up                                      *actions.preview_scroll_u
 refresh                                                          *actions.refresh*
     Refresh current directory list
 
+    Parameters:
+      {force} `boolean` When true, do not prompt user if they will be discarding
+              changes
+
 select                                                            *actions.select*
     Open the entry under the cursor
 
-select_split                                                *actions.select_split*
-    Open the entry under the cursor in a horizontal split
-
-select_tab                                                    *actions.select_tab*
-    Open the entry under the cursor in a new tab
-
-select_vsplit                                              *actions.select_vsplit*
-    Open the entry under the cursor in a vertical split
-
-send_to_loclist                                          *actions.send_to_loclist*
-    Sends files in the current oil directory to the location list, replacing the
-    previous entries.
+    Parameters:
+      {close}      `boolean` Close the original oil buffer once selection is
+                   made
+      {horizontal} `boolean` Open the buffer in a horizontal split
+      {split}      `"aboveleft"|"belowright"|"topleft"|"botright"` Split
+                   modifier
+      {tab}        `boolean` Open the buffer in a new tab
+      {vertical}   `boolean` Open the buffer in a vertical split
 
 send_to_qflist                                            *actions.send_to_qflist*
     Sends files in the current oil directory to the quickfix list, replacing the
     previous entries.
 
+    Parameters:
+      {action} `"r"|"a"` Replace or add to current quickfix list (see
+               |setqflist-action|)
+      {target} `"qflist"|"loclist"` The target list to send files to
+
 show_help                                                      *actions.show_help*
     Show default keymaps
 
-tcd                                                                  *actions.tcd*
-    :tcd to the current oil directory
-
 toggle_hidden                                              *actions.toggle_hidden*
     Toggle hidden files and directories
 
 toggle_trash                                                *actions.toggle_trash*
     Jump to and from the trash for the current directory
 
+yank_entry                                                    *actions.yank_entry*
+    Yank the filepath of the entry under the cursor to a register
+
+    Parameters:
+      {modify} `string` Modify the path with |fnamemodify()| using this as the
+               mods argument
+
 --------------------------------------------------------------------------------
 HIGHLIGHTS                                                        *oil-highlights*
 
diff --git a/lua/oil/actions.lua b/lua/oil/actions.lua
index 1bb885ee..7fc27ce2 100644
--- a/lua/oil/actions.lua
+++ b/lua/oil/actions.lua
@@ -4,20 +4,48 @@ local util = require("oil.util")
 local M = {}
 
 M.show_help = {
-  desc = "Show default keymaps",
   callback = function()
     local config = require("oil.config")
     require("oil.keymap_util").show_help(config.keymaps)
   end,
+  desc = "Show default keymaps",
 }
 
 M.select = {
   desc = "Open the entry under the cursor",
-  callback = oil.select,
+  callback = function(opts)
+    opts = opts or {}
+    local callback = opts.callback
+    opts.callback = nil
+    oil.select(opts, callback)
+  end,
+  parameters = {
+    vertical = {
+      type = "boolean",
+      desc = "Open the buffer in a vertical split",
+    },
+    horizontal = {
+      type = "boolean",
+      desc = "Open the buffer in a horizontal split",
+    },
+    split = {
+      type = '"aboveleft"|"belowright"|"topleft"|"botright"',
+      desc = "Split modifier",
+    },
+    tab = {
+      type = "boolean",
+      desc = "Open the buffer in a new tab",
+    },
+    close = {
+      type = "boolean",
+      desc = "Close the original oil buffer once selection is made",
+    },
+  },
 }
 
 M.select_vsplit = {
   desc = "Open the entry under the cursor in a vertical split",
+  deprecated = true,
   callback = function()
     oil.select({ vertical = true })
   end,
@@ -25,6 +53,7 @@ M.select_vsplit = {
 
 M.select_split = {
   desc = "Open the entry under the cursor in a horizontal split",
+  deprecated = true,
   callback = function()
     oil.select({ horizontal = true })
   end,
@@ -32,6 +61,7 @@ M.select_split = {
 
 M.select_tab = {
   desc = "Open the entry under the cursor in a new tab",
+  deprecated = true,
   callback = function()
     oil.select({ tab = true })
   end,
@@ -98,11 +128,14 @@ M.close = {
 }
 
 ---@param cmd string
-local function cd(cmd)
+---@param silent? boolean
+local function cd(cmd, silent)
   local dir = oil.get_current_dir()
   if dir then
     vim.cmd({ cmd = cmd, args = { dir } })
-    vim.notify(string.format("CWD: %s", dir), vim.log.levels.INFO)
+    if not silent then
+      vim.notify(string.format("CWD: %s", dir), vim.log.levels.INFO)
+    end
   else
     vim.notify("Cannot :cd; not in a directory", vim.log.levels.WARN)
   end
@@ -110,13 +143,31 @@ end
 
 M.cd = {
   desc = ":cd to the current oil directory",
-  callback = function()
-    cd("cd")
+  callback = function(opts)
+    opts = opts or {}
+    local cmd = "cd"
+    if opts.scope == "tab" then
+      cmd = "tcd"
+    elseif opts.scope == "win" then
+      cmd = "lcd"
+    end
+    cd(cmd, opts.silent)
   end,
+  parameters = {
+    scope = {
+      type = 'nil|"tab"|"win"',
+      desc = "Scope of the directory change (e.g. use |:tcd| or |:lcd|)",
+    },
+    silent = {
+      type = "boolean",
+      desc = "Do not show a message when changing directories",
+    },
+  },
 }
 
 M.tcd = {
   desc = ":tcd to the current oil directory",
+  deprecated = true,
   callback = function()
     cd("tcd")
   end,
@@ -200,8 +251,12 @@ M.open_external = {
       return
     end
     local path = dir .. entry.name
-    -- TODO use vim.ui.open once this is resolved
-    -- https://github.com/neovim/neovim/issues/24567
+
+    if vim.ui.open then
+      vim.ui.open(path)
+      return
+    end
+
     local cmd, err = get_open_cmd(path)
     if not cmd then
       vim.notify(string.format("Could not open %s: %s", path, err), vim.log.levels.ERROR)
@@ -214,8 +269,9 @@ M.open_external = {
 
 M.refresh = {
   desc = "Refresh current directory list",
-  callback = function()
-    if vim.bo.modified then
+  callback = function(opts)
+    opts = opts or {}
+    if vim.bo.modified and not opts.force then
       local ok, choice = pcall(vim.fn.confirm, "Discard changes?", "No\nYes")
       if not ok or choice ~= 2 then
         return
@@ -226,6 +282,12 @@ M.refresh = {
     -- :h CTRL-L-default
     vim.cmd.nohlsearch()
   end,
+  parameters = {
+    force = {
+      desc = "When true, do not prompt user if they will be discarding changes",
+      type = "boolean",
+    },
+  },
 }
 
 local function open_cmdline_with_path(path)
@@ -236,7 +298,10 @@ end
 
 M.open_cmdline = {
   desc = "Open vim cmdline with current entry as an argument",
-  callback = function()
+  callback = function(opts)
+    opts = vim.tbl_deep_extend("keep", opts or {}, {
+      shorten_path = true,
+    })
     local config = require("oil.config")
     local fs = require("oil.fs")
     local entry = oil.get_cursor_entry()
@@ -252,13 +317,53 @@ M.open_cmdline = {
     if not adapter or not path or adapter.name ~= "files" then
       return
     end
-    local fullpath = fs.shorten_path(fs.posix_to_os_path(path) .. entry.name)
+    local fullpath = fs.posix_to_os_path(path) .. entry.name
+    if opts.modify then
+      fullpath = vim.fn.fnamemodify(fullpath, opts.modify)
+    end
+    if opts.shorten_path then
+      fullpath = fs.shorten_path(fullpath)
+    end
     open_cmdline_with_path(fullpath)
   end,
+  parameters = {
+    modify = {
+      desc = "Modify the path with |fnamemodify()| using this as the mods argument",
+      type = "string",
+    },
+    shorten_path = {
+      desc = "Use relative paths when possible",
+      type = "boolean",
+    },
+  },
+}
+
+M.yank_entry = {
+  desc = "Yank the filepath of the entry under the cursor to a register",
+  callback = function(opts)
+    opts = opts or {}
+    local entry = oil.get_cursor_entry()
+    local dir = oil.get_current_dir()
+    if not entry or not dir then
+      return
+    end
+    local path = dir .. entry.name
+    if opts.modify then
+      path = vim.fn.fnamemodify(path, opts.modify)
+    end
+    vim.fn.setreg(vim.v.register, path)
+  end,
+  parameters = {
+    modify = {
+      desc = "Modify the path with |fnamemodify()| using this as the mods argument",
+      type = "string",
+    },
+  },
 }
 
 M.copy_entry_path = {
   desc = "Yank the filepath of the entry under the cursor to a register",
+  deprecated = true,
   callback = function()
     local entry = oil.get_cursor_entry()
     local dir = oil.get_current_dir()
@@ -271,6 +376,7 @@ M.copy_entry_path = {
 
 M.copy_entry_filename = {
   desc = "Yank the filename of the entry under the cursor to a register",
+  deprecated = true,
   callback = function()
     local entry = oil.get_cursor_entry()
     if not entry then
@@ -282,6 +388,7 @@ M.copy_entry_filename = {
 
 M.open_cmdline_dir = {
   desc = "Open vim cmdline with current directory as an argument",
+  deprecated = true,
   callback = function()
     local fs = require("oil.fs")
     local dir = oil.get_current_dir()
@@ -293,7 +400,14 @@ M.open_cmdline_dir = {
 
 M.change_sort = {
   desc = "Change the sort order",
-  callback = function()
+  callback = function(opts)
+    opts = opts or {}
+
+    if opts.sort then
+      oil.set_sort(opts.sort)
+      return
+    end
+
     local sort_cols = { "name", "size", "atime", "mtime", "ctime", "birthtime" }
     vim.ui.select(sort_cols, { prompt = "Sort by", kind = "oil_sort_col" }, function(col)
       if not col then
@@ -315,6 +429,12 @@ M.change_sort = {
       )
     end)
   end,
+  parameters = {
+    sort = {
+      type = "oil.SortSpec[]",
+      desc = "List of columns plus direction (see |oil.set_sort|) instead of interactive selection",
+    },
+  },
 }
 
 M.toggle_trash = {
@@ -348,16 +468,31 @@ M.toggle_trash = {
 
 M.send_to_qflist = {
   desc = "Sends files in the current oil directory to the quickfix list, replacing the previous entries.",
-  callback = function()
-    util.send_to_quickfix({
+  callback = function(opts)
+    opts = vim.tbl_deep_extend("keep", opts or {}, {
       target = "qflist",
-      mode = "r",
+      action = "r",
+    })
+    util.send_to_quickfix({
+      target = opts.target,
+      action = opts.action,
     })
   end,
+  parameters = {
+    target = {
+      type = '"qflist"|"loclist"',
+      desc = "The target list to send files to",
+    },
+    action = {
+      type = '"r"|"a"',
+      desc = "Replace or add to current quickfix list (see |setqflist-action|)",
+    },
+  },
 }
 
 M.add_to_qflist = {
   desc = "Adds files in the current oil directory to the quickfix list, keeping the previous entries.",
+  deprecated = true,
   callback = function()
     util.send_to_quickfix({
       target = "qflist",
@@ -368,6 +503,7 @@ M.add_to_qflist = {
 
 M.send_to_loclist = {
   desc = "Sends files in the current oil directory to the location list, replacing the previous entries.",
+  deprecated = true,
   callback = function()
     util.send_to_quickfix({
       target = "loclist",
@@ -378,6 +514,7 @@ M.send_to_loclist = {
 
 M.add_to_loclist = {
   desc = "Adds files in the current oil directory to the location list, keeping the previous entries.",
+  deprecated = true,
   callback = function()
     util.send_to_quickfix({
       target = "loclist",
@@ -395,6 +532,8 @@ M._get_actions = function()
       table.insert(ret, {
         name = name,
         desc = action.desc,
+        deprecated = action.deprecated,
+        parameters = action.parameters,
       })
     end
   end
diff --git a/lua/oil/config.lua b/lua/oil/config.lua
index 1defbe1e..af19c051 100644
--- a/lua/oil/config.lua
+++ b/lua/oil/config.lua
@@ -58,16 +58,16 @@ local default_config = {
   keymaps = {
     ["g?"] = "actions.show_help",
     ["<CR>"] = "actions.select",
-    ["<C-s>"] = "actions.select_vsplit",
-    ["<C-h>"] = "actions.select_split",
-    ["<C-t>"] = "actions.select_tab",
+    ["<C-s>"] = { "actions.select_split", opts = { vertical = true } },
+    ["<C-h>"] = { "actions.select_split", opts = { horizontal = true } },
+    ["<C-t>"] = { "actions.select_split", opts = { tab = true } },
     ["<C-p>"] = "actions.preview",
     ["<C-c>"] = "actions.close",
     ["<C-l>"] = "actions.refresh",
     ["-"] = "actions.parent",
     ["_"] = "actions.open_cwd",
     ["`"] = "actions.cd",
-    ["~"] = "actions.tcd",
+    ["~"] = { "actions.cd", opts = { scope = "tab" } },
     ["gs"] = "actions.change_sort",
     ["gx"] = "actions.open_external",
     ["g."] = "actions.toggle_hidden",
diff --git a/lua/oil/keymap_util.lua b/lua/oil/keymap_util.lua
index 988a9d82..834ce93c 100644
--- a/lua/oil/keymap_util.lua
+++ b/lua/oil/keymap_util.lua
@@ -10,10 +10,14 @@ local M = {}
 ---@return string|nil mode
 local function resolve(rhs)
   if type(rhs) == "string" and vim.startswith(rhs, "actions.") then
-    return resolve(actions[vim.split(rhs, ".", { plain = true })[2]])
+    local action_name = vim.split(rhs, ".", { plain = true })[2]
+    local action = actions[action_name]
+    assert(action, "Unknown action name: " .. action_name)
+    return resolve(action)
   elseif type(rhs) == "table" then
     local opts = vim.deepcopy(rhs)
-    local callback = opts.callback
+    -- We support passing in a `callback` key, or using the 1 index as the rhs of the keymap
+    local callback = resolve(opts.callback or opts[1])
     local mode = opts.mode
     if type(rhs.callback) == "string" then
       local action_opts, action_mode
@@ -21,8 +25,24 @@ local function resolve(rhs)
       opts = vim.tbl_extend("keep", opts, action_opts)
       mode = mode or action_mode
     end
+
+    -- remove all the keys that we can't pass as options to `vim.keymap.set`
     opts.callback = nil
     opts.mode = nil
+    opts[1] = nil
+    opts.deprecated = nil
+    opts.parameters = nil
+
+    if opts.opts and type(callback) == "function" then
+      local callback_args = opts.opts
+      opts.opts = nil
+      local orig_callback = callback
+      callback = function()
+        ---@diagnostic disable-next-line: redundant-parameter
+        orig_callback(callback_args)
+      end
+    end
+
     return callback, opts, mode
   else
     return rhs, {}
diff --git a/scripts/generate.py b/scripts/generate.py
index bf2358f5..7202dc86 100755
--- a/scripts/generate.py
+++ b/scripts/generate.py
@@ -2,7 +2,7 @@
 import os.path
 import re
 from dataclasses import dataclass, field
-from typing import List
+from typing import Any, Dict, List
 
 from nvim_doc_tools import (
     LuaParam,
@@ -13,7 +13,6 @@
     indent,
     leftright,
     parse_directory,
-    parse_functions,
     read_nvim_json,
     read_section,
     render_md_api2,
@@ -228,21 +227,76 @@ def get_highlights_vimdoc() -> "VimdocSection":
     return section
 
 
+def load_params(params: Dict[str, Any]) -> List[LuaParam]:
+    ret = []
+    for name, data in sorted(params.items()):
+        ret.append(LuaParam(name, data["type"], data["desc"]))
+    return ret
+
+
 def get_actions_vimdoc() -> "VimdocSection":
     section = VimdocSection("Actions", "oil-actions", ["\n"])
+    section.body.append(
+        """The `keymaps` option in `oil.setup` allow you to create mappings using all the same parameters as |vim.keymap.set|.
+>lua
+    keymaps = {
+        -- Mappings can be a string
+        ["~"] = "<cmd>edit $HOME<CR>",
+        -- Mappings can be a function
+        ["gd"] = function()
+            require("oil").set_columns({ "icon", "permissions", "size", "mtime" })
+        end,
+        -- You can pass additional opts to vim.keymap.set by using
+        -- a table with the mapping as the first element.
+        ["<leader>ff"] = {
+            function()
+                require("telescope.builtin").find_files({
+                    cwd = require("oil").get_current_dir()
+                })
+            end,
+            mode = "n",
+            nowait = true,
+            desc = "Find files in the current directory"
+        },
+        -- Mappings that are a string starting with "actions." will be
+        -- one of the built-in actions, documented below.
+        ["`"] = "actions.tcd",
+        -- Some actions have parameters. These are passed in via the `opts` key.
+        ["<leader>:"] = {
+            "actions.open_cmdline",
+            opts = {
+                shorten_path = true,
+                modify = ":h",
+            },
+            desc = "Open the command line with the current directory as an argument",
+        },
+    }
+"""
+    )
+    section.body.append("\n")
     section.body.extend(
         wrap(
-            """These are actions that can be used in the `keymaps` section of config options. You can also call them directly with `require("oil.actions").action_name.callback()`"""
+            """Below are the actions that can be used in the `keymaps` section of config options. You can refer to them as strings (e.g. "actions.<action_name>") or you can use the functions directly with `require("oil.actions").action_name.callback()`"""
         )
     )
     section.body.append("\n")
     actions = read_nvim_json('require("oil.actions")._get_actions()')
     actions.sort(key=lambda a: a["name"])
     for action in actions:
+        if action.get("deprecated"):
+            continue
         name = action["name"]
         desc = action["desc"]
         section.body.append(leftright(name, f"*actions.{name}*"))
         section.body.extend(wrap(desc, 4))
+        params = action.get("parameters")
+        if params:
+            section.body.append("\n")
+            section.body.append("    Parameters:\n")
+            section.body.extend(
+                format_vimdoc_params(load_params(params), LuaTypes(), 6)
+            )
+
         section.body.append("\n")
     return section