DEV Community

Cover image for Configuring Neovim with development experience in mind
Diego Frattini
Diego Frattini

Posted on • Edited on

Configuring Neovim with development experience in mind

Photo by Dries Augustyns on Unsplash

A long time passed since I wrote of my approach of migrating from VSCode to Neovim. That article saw its birth just after an intense first round of wrestling with the terminal editor as a developer with a traditional IDE experience.
I was happy with the results. In fact, I managed to code with reasonable speed in a matter of weeks.

The early achievements left me excited, with many ideas for improvements and countless doubts on how to achieve many of them. That must be the general feeling of most Vim/Neovim new adopters.

Today I am sharing how I continued to enhance my configuration, grouping related functionalities into domains and organising my modules in a tree structure. I am also showcasing a few non trivial custom integrations targeted at improving the experience of using the terminal editor.

Solving the right problem

While developing a (Neo)Vim configuration, one experiences being end user and solution provider at the same time. It is not by chance that most vim plugins used to be pieces of user configuration refined over time, later on made independent and packaged for sharing.

It made sense then to lay a list of personal pain points and expectations to face as challenges to solve while extending my configuration. The list came on the back of the configuration first pass, with all its horrific pragmatic compromises.

Challenge #1: Code split

When it comes to code organisation most configurations I have seen do one of the following: they either put everything inside one big file or they split into multiple files having one of them dedicated to global options, one to plugins management, one to each plugin’s setup, one to keyboard mappings, and so on.

Having a single file allows to manage everything in one place, although it soon becomes uneasy to maintain. Dividing seems to make sense, but the mentioned split criteria forces to make changes in multiple locations whenever a tweak is needed.

I opted instead for organising my setup by grouping functionalities based on their coupling, following an approach inspired by DDD.
Correlated plugins, their declarations, setup and associated customisations ends up arranged in their own module, separated from other ones containing unrelated sets of configurations.
Here is the Editor module as example:

-- ~/.config/nvim/lua/editor/init.lua

local Module = require("_shared.module")

---@class Editor
local Editor = {}

Editor.modules = {
    "editor.syntax",
    "editor.language",
    "editor.completion",
    "editor.spelling",
}

Editor.plugins = {
    -- Comments
    "b3nj5m1n/kommentary",
    -- Autoclosing pair of chars
    "windwp/nvim-autopairs",
    -- Parentheses, brackets, quotes, XML tags
    "tpope/vim-surround",
    -- Change case and handles variants of a word
    "tpope/vim-abolish",
    -- additional operator targets
    "wellle/targets.vim",
    -- Highlighting command ranges
    { "winston0410/range-highlight.nvim", requires = "winston0410/cmd-parser.nvim" },
}

function Editor:setup()
    -- Kommentary
    vim.g.kommentary_create_default_mappings = false

    -- Range highlight
    require("range-highlight").setup()

    -- ...
end

return Module:new(Editor)
Enter fullscreen mode Exit fullscreen mode

Defining the correct number of domains and their boundaries is one of the most challenging aspects of similar architecture choices.
At the time of writing, modules organisation looks like the following:

config
├─ editor
│  ├─ completion
│  ├─ language
│  ├─ spelling
│  ├─ syntax
├─ finder
│  ├─ list
│  ├─ picker
├─ interface
│  ├─ tab
│  ├─ theme
│  ├─ window
├─ project
│  ├─ git
│  ├─ github
│  ├─ tree
├─ terminal
├─ _shared
Enter fullscreen mode Exit fullscreen mode

I am not following DDD principles strictly and I am instead breaking several of them. For example domain consumers reach directly for subdomains.
There is a hierarchical relationship among domains and subdomains: all modules are logically tied together in a tree structure thanks to the Module class every configuration node is an instance of:

-- ~/.config/nvim/lua/_shared/module.lua

---Represents a configuration module
---@class Module
local Module = {}

--- Module specific plugins
Module.plugins = {}

--- Module submodules
Module.modules = {}

--- Module specific configurations
Module.setup = function() end

--- Initializes the module
function Module:init()
    self:setup()

    for _, child_module_name in ipairs(self.modules) do
        local child_module = require(child_module_name)

        child_module:init()
    end
end

---@generic M
---@type fun(module: M): Module | M
function Module:new(m)
        setmetatable(m, self)
        self.__index = self

        -- Sourcing submodules
        for _, child_module_name in ipairs(m.modules) do
            require(child_module_name)
        end

        return m
end

return Module
Enter fullscreen mode Exit fullscreen mode

When the configuration gets sourced, the root config module is initialised, initialising in turn all other modules in recursive fashion:

-- ~/.config/nvim/init.lua

local config = require("config")

config:init()
Enter fullscreen mode Exit fullscreen mode

The tree logical organisation also solves one of the major challenges presented by placing plugins declarations in different files.
Packer.nvim prescribes all plugins to be registered in a single startup call. I couldn’t therefore let modules register them independently.
Instead, I leveraged tree search to gather all plugins declarations in a single place:

-- ~/.config/nvim/lua/_shared/module.lua

---Represents a configuration module
---@class Module
local Module = {}

-- ...

--- Returns a flat list of all the plugins used by the module and by its children
function Module:list_plugins()
    local plugins = vim.deepcopy(self.plugins)
    local child_modules = self.modules

    for _, child_module_name in ipairs(child_modules) do
        local child_module = require(child_module_name)
        local child_module_plugins = child_module:list_plugins()

        for _, child_module_plugin in ipairs(child_module_plugins) do
            table.insert(plugins, child_module_plugin)
        end
    end

    return plugins
end

return Module
Enter fullscreen mode Exit fullscreen mode

The root config module takes care of installation procedures and feeding the list of plugins to packer by overriding the init method:

-- ~/.config/nvim/lua/config.lua

local Module = require("_shared.module")

local installed = nil
local install_path = vim.fn.stdpath("data") .. "/site/pack/packer/start/packer.nvim"
local config_files = vim.fn.expand("~", false) .. "/.config/nvim/**/*"

local Config = {}

Config.plugins = {
    "wbthomason/packer.nvim",
}

Config.modules = {
    "interface",
    "project",
    "editor",
    "finder",
    "terminal",
}

function Config:init()
    -- Checking packer install location
    installed = vim.fn.empty(vim.fn.glob(install_path)) == 0

    -- Cloning packer in place if it is not found
    if not installed then
        print("Installing plugins...")
        vim.fn.execute("!git clone https://github.com/wbthomason/packer.nvim " .. install_path)
        vim.cmd([[packadd packer.nvim]])
    end

    -- Registering plugins to use
    require("packer").startup(function(use)
        use(self:list_plugins())
    end)

    -- Installing plugins
    if not installed then
        local group = vim.api.nvim_create_augroup("OnPackerSyncComplete", { clear = true, once = true })
        vim.api.nvim_create_autocmd("User", {
            group = group,
            pattern = "PackerComplete",
            callback = function()
                self:init()
                vim.cmd("packloadall!")
            end,
        })

        require("packer").sync()
        return
    end

    self:setup()

    for _, child_module_name in ipairs(self.modules) do
        local child_module = require(child_module_name)

        child_module:init()
    end
end

function Config:setup()
    -- ...
end

return Module:new(Config)
Enter fullscreen mode Exit fullscreen mode

When Neovim is launched, the root module detects whether it is needed to perform a full installation by checking for the presence of packer. That happens before any other module is initialised, avoiding errors given by uninstalled dependencies. Thanks to the PackerComplete command exposed by the plugin manager, it is possible to delay the actual initialisation until plugins are installed and ready. Nice.

Neovim first intallation

The final goal remains to have related plugins and associated setup in a single file. When any customisation tweaks will be needed, all relevant code will be found in the same location.
It results trivial to add, remove and refactor config modules as their content is mostly independent from other modules.

Challenge #2: Shared settings

Modules split and tree structure deal with plugins and related customisations.
Neovim options represent instead a shared concern for all configuration domains. Nonetheless, options are expected to be found and managed in a single location because many of them interacts with one another.
The same can be stated for user keymaps as it is generally needed to avoid overlapping and to find consistent patterns to facilitate mnemonics.

It is possible to centralise global options and keymaps through a dedicated interface. Even selected plugin options benefit from being managed there, allowing to tweak them without changing associated modules:

-- ~/.config/nvim/lua/settings.lua

local Settings = {}

Settings._globals = {
    -- Asking for confirmation instead of just failing certain commands
    confirm = true,
    -- Incremental live completion (note: this is now a default on master)
    inccommand = "nosplit",
    -- Set highlight on search
    hlsearch = true,
    -- Highlighting the cursor line
    cul = true,
    -- Avoid rerendering during macros, registers etc
    lazyredraw = true,

    -- ...

}

function Settings.globals(globals)
    if not globals then
        return Settings._globals
    end

    Settings._globals = vim.tbl_extend("force", Settings._globals, globals)

    for option_name, option_value in pairs(Settings._globals) do
        vim.opt[option_name] = option_value
    end

    return Settings._globals
end

Settings._keymaps = {
    leader = " ",
    -- Buffers navigation
    ["buffer.next"] = "<A-Tab>",
    ["buffer.prev"] = "<A-S-Tab>",
    -- Write only if changed
    ["buffer.save"] = "<leader>w",
    -- Write all
    ["buffer.save.all"] = "<leader>W",
    -- Quit (or close window)
    ["buffer.close"] = "<leader>q",
    -- Delete buffer
    ["buffer.close.delete"] = "<leader>Q",

    -- ...

}

Settings.keymaps = validator.f.arguments({
    validator.f.optional(validator.f.shape({
        leader = validator.f.optional("string"),
    })),
}) .. function(keymaps)
    if not keymaps then
        return Settings._keymaps
    end

    Settings._keymaps = vim.tbl_extend("force", Settings._keymaps, keymaps)

    return Settings._keymaps
end

Settings._options = {
    ["language.parsers"] = {},
    ["language.servers"] = {},
    ["theme.colorscheme"] = "nightfox",
    ["theme.component_separator"] = "│",
    ["theme.section_separator"] = "█",
    ["terminal.jobs"] = {},
}

Settings.options = function(options)
    if not options then
        return Settings._options
    end

    Settings._options = vim.tbl_extend("force", Settings._options, options)

    return Settings._options
end

return setmetatable(Settings, {
    __call = function(self, settings)
        local globals = settings.globals or {}
        local keymaps = settings.keymaps or {}
        local options = settings.options or {}

        self.globals(globals)
        self.keymaps(keymaps)
        self.options(options)

        return self
    end,
})
Enter fullscreen mode Exit fullscreen mode

Configuration modules consume globals, keymaps and plugin options by using provided getters. The idea is to decouple customisation logics from raw values:

-- ~/.config/nvim/lua/editor/init.lua

local Module = require("_shared.module")
local settings = require("settings")

---@class Editor
local Editor = {}

-- ...

function Editor:setup()
    -- ...

    local keymaps = settings.keymaps()

    vim.keymap.set(
        "n",
        keymaps["buffer.next"],
        ":bnext<Cr>",
        { noremap = true, silent = true }
    )

    -- ...
end

return Module:new(Editor)
Enter fullscreen mode Exit fullscreen mode

Shared settings are handled independently and initialised just before modules are.
The existence of a dedicated interface enables injecting overrides from external sources, effectively simulating the behaviour of software that allow to manage user preferences using a dedicated user settings file:

-- ~/.config/nvim/init.lua

local settings = require("settings")
local config = require("config")

-- User settings
local custom_settings_file = vim.fn.stdpath("config") .. "/settings.lua"
local custom_settings = vim.fn.filereadable(custom_settings_file) == 1 and dofile(custom_settings_file) or {}
settings(custom_settings)

-- Initialisation
config:init()
Enter fullscreen mode Exit fullscreen mode

I didn’t yet dig deeper into all possibilities unlocked by the use of a settings file.
An idea that comes to mind is to give the chance of overriding values depending on the working directory path, allowing to define different setups for different projects.

Challenge #3: Validation

Lua is a powerful little language. Despite its simplicity, it provides all building blocks required to replicate advanced constructs.

The only feature I miss dearly when coding in Lua is a type system.
Knowing whether a specific method was called with an incorrect signature before appreciating the code blowing up at runtime is quite valuable. Also, the process of debugging without the support of a type system can be sometimes more than frustrating.

In dynamically typed language contexts the situation can be mitigated by configuring a language server which will provide diagnostics either by inferring types from code or parsing purposely written annotations.
Besides, runtime validation is often applied by developers when needed. Neovim specifically offers vim.validate api for validation.

/usr/local/Cellar/neovim/0.8.1/share/nvim/runtime/doc/lua.txt

validate({opt})
    Validates a parameter specification (types and values).

    Usage example:  

      function user.new(name, age, hobbies)
        vim.validate{
          name={name, 'string'},
          age={age, 'number'},
          hobbies={hobbies, 'table'},
        }
        ...
      end
Enter fullscreen mode Exit fullscreen mode

There are two main factors making my eye twitch when validation is applied this way.
Firstly, that is an imperative approach and details of arguments validation ends up mixed with logics consuming those.
Secondly, the signature of vim.validate is inconsistent and can be called in different ways:

/usr/local/Cellar/neovim/0.8.1/share/nvim/runtime/doc/lua.txt

Parameters:  
      • {opt}  (table) Names of parameters to validate. Each key is a
               parameter name; each value is a tuple in one of these forms:
               1. (arg_value, type_name, optional)
                  • arg_value: argument value
                  • type_name: string|table type name, one of: ("table", "t",
                    "string", "s", "number", "n", "boolean", "b", "function",
                    "f", "nil", "thread", "userdata") or list of them.
                  • optional: (optional) boolean, if true, `nil` is valid

               2. (arg_value, fn, msg)
                  • arg_value: argument value
                  • fn: any function accepting one argument, returns true if
                    and only if the argument is valid. Can optionally return
                    an additional informative error message as the second
                    returned value.
                  • msg: (optional) error string if validation fails
Enter fullscreen mode Exit fullscreen mode

Whilst this was possibly done to offer more flexibility, I preferred instead to create my own wrapper around it with the purpose of unifying validation apis and providing a declarative interface to consumers.

Decorators are a good candidate for applying arguments validation without modifying decorated functions. Lua doesn’t offer decorators natively, but it is relatively easy to implement them using metamethods. I decided to use __concat , which visually results in concatenating arguments validations to their target functions:

-- ~/.config/nvim/lua/_shared/key.lua

local validator = require("_shared.validator")

local Key = {}

-- ...

--- Replaces terminal codes and keycodes (<CR>, <Esc>, ...) in a string with the internal representation.
---@type fun(keys: string)
Key.to_term_code = validator.f.arguments({ "string" })
    .. function(keys)
        return vim.api.nvim_replace_termcodes(keys, true, true, true)
    end

--- Characters in keys are queued for processing as if they
--- come from a mapping or were typed by the user.
--- Replaces terminal codes and keycodes (<CR>, <Esc>, ...) in a
--- string with the internal representation.
---@type fun(keys: string, mode?: string): number
Key.input = validator.f.arguments({ "string", validator.f.optional(validator.f.pattern("^[mntix!]+$")) })
    .. function(keys, input_mode)
        local mode = input_mode or "n" -- Noremap mode by default
        return Key.feed(Key.to_term_code(keys), mode)
    end

return Key
Enter fullscreen mode Exit fullscreen mode

Validations are defined in schema-inspired fashion by using validator.f.arguments, which in turn accepts a list of validators mapped to function arguments by order.
Decorating a function with validation ultimately means to wrap it in another function which will intercepts its arguments performing assertions on them, either throwing an exception or eventually calling the decorated function with valid arguments:

-- ~/.config/nvim/lua/_shared/validator.lua

local Validator = {}

Validator.f = {

    -- ...

    ---@class ArgumentsValidator
    ---@operator concat:function
    ---@operator call:function

    ---Generates a function decorator which validates arguments passed to the decorated function
    ---@param arguments_validators table validators to use for function arguments
    ---@param error_message? string error message thrown
    ---@return ArgumentsValidator
    arguments = function(arguments_validators, error_message)
        error_message = error_message or "Arguments validation error: %s"
        local validate_arguments = Validator.f.list(arguments_validators)

        return setmetatable({
            decorate = function(func)
                return function(...)
                    local valid, validation_error = validate_arguments({ ... })

                    if not valid then
                        error(string.format(error_message, validation_error))
                    end

                    return func(...)
                end
            end,
        }, {
            __call = function(self, ...)
                return self.decorate(...)
            end,
            __concat = function(self, ...)
                return self.decorate(...)
            end,
        })
    end,
}

return Validator
Enter fullscreen mode Exit fullscreen mode

Validators are generated at function definition and triggered at function call. They can be composed as needed to achieve the level of validation detail required.
Besides, they can still be used imperatively for code reusability purposes or when the situation calls for it:

local validator = require("_shared.validator")

---@alias TreeNode
--- | {name: string} # Node representing the parent directory
--- | {absolute_path: string, fs_stat: { type: string } } # Node

local validate_node = validator.f.any_of({
    -- Directory or file
    validator.f.shape({
        absolute_path = "string",
        fs_stat = validator.f.shape({
            type = "string",
        }),
    }),
    -- Upper directory
    validator.f.shape({
        name = "string",
    }),
})

-- Using the validator directly
local node = { absolute_path = "/node/path", fs_stat = { type = "file" } }
validate_node(node)

---@class Project.Tree
local Tree = {}

---@type fun(self: Project.Tree, node: TreeNode)
Tree.search_in_node = validator.f.arguments({ validator.f.equal(Tree), validate_node })
    .. function(_, node)
        -- ...
    end

---@type fun(self: Project.Tree, node: TreeNode)
Tree.tree_actions_menu = validator.f.arguments({ validator.f.equal(Tree), validate_node })
    .. function(self, node)
        -- ...
    end
Enter fullscreen mode Exit fullscreen mode

Validation logics is now completely decoupled from function bodies. Validation rules are defined in a declarative manner, hiding technical details behind a readable interface.

In all honesty, I started abstracting a few utilities to make vim.validate more consistent and, like it often happens, I ended up writing a small library. I am happy with it though as it currently covers any validation needs I may have and it has proven straightforward to extend.

Internal validation behaviours are a bit complex to be fully described in this article. The full code is found here, though.

Challenge #4: Experience

Neovim saw dramatic usability improvements if compared to its father Vim, although it suffers from the same lack of discoverability which makes long time users still unaware of all functionalities available to them.
That justifies the popularity of plugins like which-key, which displays a popup with possible key bindings completing a started command.

I never got around to integrate which-key in my configuration, but I longed nonetheless for having access to contextual actions without the need to remember them all.
Many software provide similar helpful capabilities by using dropdown menus so I decided to try and use telescope to build a customisable menu picker, which I could reuse whenever needed:

-- ~/.config/nvim/lua/finder/picker.lua

local Module = require("_shared.module")
local fn = require("_shared.fn")
local validator = require("_shared.validator")

---@class picker
local Picker = {}

-- ...

---@type fun(self: Picker, menu: { [number]: string, on_select: function }, options: { prompt_title: string } | nil)
Picker.menu = validator.f.arguments({
    validator.f.equal(Picker),
    validator.f.shape({
        validator.f.list({ "string", validator.f.optional("string") }),
        on_select = "function",
    }),
    validator.f.optional(validator.f.shape({
        prompt_title = "string",
    })),
})
    .. function(_, menu, options)
        options = options or {}

        local entry_display = require("telescope.pickers.entry_display")
        local displayer = entry_display.create({
            separator = " ",
            items = {
                -- calculating the max width needed for the first column
                fn.ireduce(menu, function(item, result)
                    item.width = math.max(item.width, #result[1])
                    return item
                end, { width = 10 }),
                { remaining = true },
            },
        })
        local make_display = function(entry)
            return displayer({
                entry.value[1],
                { entry.value[2] or "", "Comment" },
            })
        end
        local entry_maker = function(menu_item)
            return {
                value = menu_item,
                ordinal = menu_item[1],
                display = make_display,
            }
        end

        local finder = require("telescope.finders").new_table({
            results = fn.imap(menu, function(menu_item)
                return menu_item
            end),
            entry_maker = entry_maker,
        })
        local sorter = require("telescope.sorters").get_generic_fuzzy_sorter()
        local default_options = {
            finder = finder,
            sorter = sorter,
            attach_mappings = function(prompt_buffer_number)
                local actions = require("telescope.actions")
                local state = require("telescope.actions.state")
                -- On select item
                actions.select_default:replace(function()
                    menu.on_select({ buffer = prompt_buffer_number, state = state, actions = actions })
                end)
                -- Disabling any kind of multiple selection
                actions.add_selection:replace(function() end)
                actions.remove_selection:replace(function() end)
                actions.toggle_selection:replace(function() end)
                actions.select_all:replace(function() end)
                actions.drop_all:replace(function() end)
                actions.toggle_all:replace(function() end)

                return true
            end,
        }

        require("telescope.pickers").new(options, default_options):find()
    end

---@type fun(self: Picker, menu: { [number]: string, on_select: function }, options: { prompt_title: string } | nil)
function Picker:context_menu(menu, options)
    local theme = require("telescope.themes").get_cursor()
    options = vim.tbl_extend("force", theme, options or {})

    return Picker:menu(menu, options)
end

return Module:new(Picker)
Enter fullscreen mode Exit fullscreen mode

The signature shared by menu and context_menu methods abstracts away the picker’s underlying requirements, allowing to pass a list of menu items alongside a callback triggered on selection.
Besides information necessary to the picker display, extra data can be attached to entries. That comes in handy when handling item selection:

-- ~/.config/nvim/lua/project/tree.lua

local Module = require("_shared.module")
local key = require("_shared.key")
local validator = require("_shared.validator")
local fn = require("_shared.fn")
local settings = require("settings")

---@class Project.Tree
local Tree = {}

-- ...

function Tree:setup()
    require("nvim-tree").setup({
        -- ...

        on_attach = function(tree_buffer)
            local actions = self:actions()
            local mappings = fn.imap(actions, function(action)
                return { action.keymap, action.handler, buffer = tree_buffer }
            end)

            key.nmap(unpack(mappings))

            key.nmap({
                keymaps["dropdown.open"],
                require("nvim-tree.utils").inject_node(fn.bind(self.tree_actions_menu, self)),
                buffer = tree_buffer
            })
        end,
    })
end

function Tree:actions()
    local keymaps = settings.keymaps()

    return {
        {
            name = "Edit in vertical split",
            keymap = keymaps["project.tree.node.open.vertical"],
            handler = require("nvim-tree.api").node.open.vertical,
        },
        {
            name = "Edit in horizontal split",
            keymap = keymaps["project.tree.node.open.horizontal"],
            handler = require("nvim-tree.api").node.open.horizontal,
        },
        {
            name = "Edit in tab",
            keymap = keymaps["project.tree.node.open.tab"],
            handler = require("nvim-tree.api").node.open.tab,
        },

        -- ...
    }
end

---@type fun(self: Project.Tree, node: TreeNode)
Tree.tree_actions_menu = validator.f.arguments({ validator.f.equal(Tree), validate_node })
    .. function(self, node)
        local actions = self:actions()
        local menu = vim.tbl_extend(
            "error",
            fn.imap(actions, function(action)
                return { action.name, action.keymap, handler = action.handler }
            end),
            {
                on_select = function(context_menu)
                    local selection = context_menu.state.get_selected_entry()
                    context_menu.actions.close(context_menu.buffer)
                    selection.value.handler(node)
                end,
            }
        )
        local options = { prompt_title = node.name }

        require("finder.picker"):context_menu(menu, options)
    end

return Module:new(Tree)
Enter fullscreen mode Exit fullscreen mode

The code example above showcases opening a context menu containing nvim-tree actions:

Nvim-tree actions menu

By the way, nvim-tree.utils inject_node is a higher order function which passes data of the node found at cursor position into the received function, making keymap callbacks potentially capable of dynamically change the menu depending on it.
Likewise, it can be used to add behaviours not initially present among the tree explorer default actions:

-- ~/.config/nvim/lua/project/tree.lua

local Module = require("_shared.module")
local key = require("_shared.key")
local validator = require("_shared.validator")
local fn = require("_shared.fn")
local settings = require("settings")

---@class Project.Tree
local Tree = {}

-- ...

function Tree:setup()
    require("nvim-tree").setup({
        -- ...

        on_attach = function(tree_buffer)
            local actions = self:actions()
            local mappings = fn.imap(actions, function(action)
                return { action.keymap, action.handler, buffer = tree_buffer }
            end)

            key.nmap(unpack(mappings))

            key.nmap({
                keymaps["dropdown.open"],
                require("nvim-tree.utils").inject_node(fn.bind(self.tree_actions_menu, self)),
                buffer = tree_buffer
            })
        end,
    })
end

function Tree:actions()
    local keymaps = settings.keymaps()

    return {
        -- ...

        {
            name = "Search node contents",
            keymap = keymaps["project.tree.search.node.content"],
            handler = require("nvim-tree.utils").inject_node(fn.bind(self.search_in_node, self)),
        },

        -- ...
    }
end

---@type fun(self: Project.Tree, node: TreeNode)
Tree.search_in_node = validator.f.arguments({ validator.f.equal(Tree), validate_node })
    .. function(_, node)
        -- when the selected node is the one pointing at the parent directory, absolute_path will not be present
        if not node.absolute_path then
            return require("finder.picker"):text()
        end

        -- live_grep in highlighted directory
        if node.fs_stat.type == "directory" then
            return require("finder.picker"):text(node.absolute_path)
        end

        -- open file and current_buffer_fuzzy_find
        if node.fs_stat.type == "file" then
            require("nvim-tree.actions.node.open-file").fn("edit_in_place", node.absolute_path)
            return require("finder.picker"):buffer_text()
        end
    end

return Module:new(Tree)

Enter fullscreen mode Exit fullscreen mode

In such manner it is trivial to integrate between nvim-tree and telescope built-in pickers. The example uses one keymap only, but the handler acts differently based on the currently highlighted node. If the cursor is on a directory the live grep picker will be displayed to search within the folder. If the cursor is on a file instead its buffer will be opened and the current buffer fuzzy find picker displayed.
A gif is worth a thousand words:

Nvim-tree and Telescope pickers integration

Grouping related functionalities together helps making them more accessible at the cost of adding just a few keystrokes.

After gloating for a while looking at the completed context menu implementation, it stroked me that the same idea could be scaled to menus themselves.
I could gloat even more if I managed to provide an interface to create “tabbed” collections of pickers and navigate them reusing the same keymaps used to navigate buffers:

-- ~/.config/nvim/lua/finder/picker.lua

local Module = require("_shared.module")
local key = require("_shared.key")
local fn = require("_shared.fn")
local validator = require("_shared.validator")
local settings = require("settings")

---@class Finder.Picker.Tab
---@field prompt_title string
---@field find function

---@class Finder.Picker.Tabs
local Tabs = {}

---@type fun(self: Finder.Picker.Tabs, tabs: Finder.Picker.Tab[]): Finder.Picker.Tabs
Tabs.new = validator.f.arguments({
    -- This would be cool but it is not possible because builtin pickers are launched directly
    -- validator.f.list({ validator.f.instance_of(require("telescope.pickers")._Picker) }),
    validator.f.equal(Tabs),
    validator.f.list({ validator.f.shape({ prompt_title = "string", find = "function" }) }),
})
    .. function(self, tabs)
        tabs._current = 1

        setmetatable(tabs, self)
        self.__index = self

        return tabs
    end

function Tabs:_options()
    return {
        prompt_title = self:_prompt_title(),
        attach_mappings = fn.bind(self._attach_mappings, self),
    }
end

-- NOTE: this is a lot of code just to calculate a fancy prompt title
-- TODO: refactor?
function Tabs:_prompt_title()
    local globals = settings.globals()
    local current_picker_title = "[ " .. self[self._current].prompt_title .. " ]"

    -- Creating a table containing all titles making up for the left half of the title
    -- starting from the left half of the current picker title and looping backward
    local i_left = self._current - 1
    local prev_picker_titles = { string.sub(current_picker_title, 1, math.floor(#current_picker_title / 2)) }
    repeat
        if i_left < 1 then
            i_left = #self
        else
            table.insert(prev_picker_titles, 1, self[i_left].prompt_title)
            i_left = i_left - 1
        end
    until i_left == self._current

    -- Creating a table containing all titles making up for the right half of the title
    -- starting from the right half of the current picker title and looping onward
    local i_right = self._current + 1
    local next_picker_titles = {
        string.sub(current_picker_title, (math.floor(#current_picker_title / 2)) + 1, #current_picker_title),
    }
    repeat
        if i_right > #self then
            i_right = 1
        else
            table.insert(next_picker_titles, self[i_right].prompt_title)
            i_right = i_right + 1
        end
    until i_right == self._current

    -- Merging left and right, capping at 40 chars length
    local prompt_title_left = string.reverse(
        string.sub(string.reverse(table.concat(prev_picker_titles, " ")), 1, (20 - #globals.listchars.precedes))
    )
    local prompt_title_right = string.sub(table.concat(next_picker_titles, " "), 1, (20 - #globals.listchars.extends))
    local prompt_title = globals.listchars.precedes
        .. prompt_title_left
        .. prompt_title_right
        .. globals.listchars.extends

    return prompt_title
end

---@param buffer number
---@return boolean
function Tabs:_attach_mappings(buffer)
    local keymaps = settings.keymaps()

    key.imap({
        keymaps["buffer.next"],
        fn.bind(self.next, self),
        buffer = buffer,
    }, {
        keymaps["buffer.prev"],
        fn.bind(self.prev, self),
        buffer = buffer,
    })

    return true
end

function Tabs:prev()
    self._current = self._current <= 1 and #self or self._current - 1

    local options = self:_options()
    local picker = self[self._current]

    return picker.find(options)
end

function Tabs:next()
    self._current = self._current >= #self and 1 or self._current + 1

    local options = self:_options()
    local picker = self[self._current]

    return picker.find(options)
end

function Tabs:find()
    local options = self:_options()
    local picker = self[self._current]

    return picker.find(options)
end

---@class Picker
local Picker = {}

-- ...

function Picker:setup()
    local keymaps = settings.keymaps()

    key.nmap(
        -- ...

        { keymaps["find.help"], fn.bind(self.help, self) },

        -- ...
    )
end

function Picker:tabs(pickers)
    return Tabs:new(pickers)
end

function Picker:help()
    return self:tabs({
        { prompt_title = "Help", find = require("telescope.builtin").help_tags },
        { prompt_title = "Commands", find = require("telescope.builtin").commands },
        { prompt_title = "Options", find = require("telescope.builtin").vim_options },
        { prompt_title = "Autocommands", find = require("telescope.builtin").autocommands },
        { prompt_title = "Keymaps", find = require("telescope.builtin").keymaps },
        { prompt_title = "Filetypes", find = require("telescope.builtin").filetypes },
        { prompt_title = "Highlights", find = require("telescope.builtin").highlights },
    }):find()
end

-- ...

return Module:new(Picker)
Enter fullscreen mode Exit fullscreen mode

Similarly to what happens with custom menus, the picker tabs functionality is exposed by the finder.picker configuration module api so that other domains can consume it to build their own picker collections. Menus and tabs are compatible and one could create tabbed pickers out of custom menus.
The only argument required is the list of pickers, each described by prompt_title and find catering for picker’s title and launch function:

Telescope tabbed pickers

I am overall satisfied with menus and tabbed pickers solutions as they are proving useful to my development flows. Thanks to the heavy lifting done by telescope, they ease from the need of brute-force-memorising all commands by making them more accessible.

Conclusions

My main goals with Neovim revolve around making my configuration easier to maintain, scalable and having a more friendly experience while using it.

The code samples presented in this article were simplified, trying to focus them on the topics discussed. The full code is found in my dotfiles repository. Like any (Neo)Vim configuration that is permanently WIP.

Please keep in mind that I possibly spent more time than I should have on the improvements described. I am indeed treating my config almost like it was a commercial project and not my own customisation. It is likely that some of the examples will look over-engineered for the purpose of building a personal setup.
In any case, I am having plenty of fun learning about both Neovim and Lua.

If a bit of self-promotion is allowed, I maintain Sherpa as an open source project dedicated to free learning.
I collect and organise educative resources with the goal of helping people learn about dev topics, giving an alternative to paid subscriptions platforms. I also created a Neovim and Lua learning paths there! Check them out if you are interested.

Top comments (0)