Configuring Neovim with development experience in mind
Tags: neovim, showdev
Published: 2023-01-03 10:30:16

<small>Photo by [Dries Augustyns](https://unsplash.com/@driaug?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText) on [Unsplash](https://unsplash.com/?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText)</small>

A long time passed since I wrote of my approach of [migrating from VSCode to Neovim](https://dev.to/tolomeo/a-pragmatic-approach-to-migrating-from-vscode-to-neovim-4fae). 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:

```lua
-- ~/.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)
```

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
```

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:

```lua
-- ~/.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
```

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

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

local config = require("config")

config:init()
```

The tree logical organisation also solves one of the major challenges presented by placing plugins declarations in different files.
[Packer.nvim](https://github.com/wbthomason/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:

```lua
-- ~/.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
```

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

```lua
-- ~/.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)
```

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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/b6dh83my6i3kktbgop7r.gif)

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:

```lua
-- ~/.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,
})
```

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

```lua
-- ~/.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)
```

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:

```lua
-- ~/.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()
```

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](https://github.com/sumneko/lua-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
```

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
```

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](http://lua-users.org/wiki/DecoratorsAndDocstrings). I decided to use `__concat` , which visually results in concatenating arguments validations to their target functions:

```lua
-- ~/.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
```

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:

```lua
-- ~/.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
```

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:

```lua
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
```

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](https://github.com/Tolomeo/.cfg/blob/macOS/.config/nvim/lua/_shared/validator.lua), 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](https://github.com/folke/which-key.nvim), 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](https://github.com/nvim-telescope/telescope.nvim) to build a customisable menu picker, which I could reuse whenever needed:

```lua
-- ~/.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)
```

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:

```lua
-- ~/.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)
```

The code example above showcases opening a context menu containing [nvim-tree](https://github.com/nvim-tree/nvim-tree.lua) actions:

![Nvim-tree actions menu](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/mwtnf6zgoioku7ey0lr0.gif)

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:

```lua
-- ~/.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)

```

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](https://github.com/nvim-telescope/telescope.nvim/wiki/Showcase#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](https://github.com/nvim-telescope/telescope.nvim/wiki/Showcase#current-buffer-fuzzy-find) picker displayed.
A gif is worth a thousand words:

![Nvim-tree and Telescope pickers integration](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/lrrfiuewtm7z5de8tr48.gif)

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:

```lua
-- ~/.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)
```

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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/30ab1mitzc3kgaadvzia.gif)

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](https://github.com/Tolomeo/.cfg). 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](https://www.sherpa.guide/) 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](https://www.sherpa.guide/paths/neovim) and [Lua](https://www.sherpa.guide/paths/lua) learning paths there! Check them out if you are interested.