docs: add guide for developing Lua plugins

This commit is contained in:
Marc Jakobi 2024-05-28 20:47:49 +02:00
parent 8ba73f0e4c
commit 6d3ff992ff

396
runtime/doc/lua-plugin.txt Normal file
View File

@ -0,0 +1,396 @@
*lua-plugin.txt* Nvim
NVIM REFERENCE MANUAL
Guide to developing Lua plugins for Nvim
Type |gO| to see the table of contents.
==============================================================================
Introduction *lua-plugin*
This is a guide for getting started with Nvim plugin development. It is not
intended as a set of rules, but as a collection of recommendations for good
practices.
For a guide to using Lua in Nvim, please refer to |lua-guide|.
==============================================================================
Type safety *lua-plugin-type-safety*
Lua, as a dynamically typed language, is great for configuration. It provides
virtually immediate feedback.
But for larger projects, this can be a double-edged sword, leaving your plugin
susceptible to unexpected bugs at the wrong time.
You can leverage LuaCATS https://luals.github.io/wiki/annotations/
annotations, along with lua-language-server https://luals.github.io/ to catch
potential bugs in your CI before your plugin's users do.
------------------------------------------------------------------------------
Tools *lua-plugin-type-safety-tools*
- lua-typecheck-action https://github.com/marketplace/actions/lua-typecheck-action
- luacheck https://github.com/lunarmodules/luacheck for additional linting
==============================================================================
User commands *lua-plugin-user-commands*
Many users rely on command completion to discover available user commands. If
a plugin pollutes the command namespace with lots of commands, this can
quickly become overwhelming.
Example:
- `FooAction1 {arg}`
- `FooAction2 {arg}`
- `FooAction3`
- `BarAction1`
- `BarAction2`
Instead of doing this, consider gathering subcommands under scoped commands
and implementing completions for each subcommand.
Example:
- `Foo action1 {arg}`
- `Foo action2 {arg}`
- `Foo action3`
- `Bar action1`
- `Bar action2`
------------------------------------------------------------------------------
Subcommand completions example *lua-plugin-user-commands-completions-example*
In this example, we want to provide:
- Subcommand completions if the user has typed `:Foo ...`
- Argument completions if they have typed `:Foo {subcommand}`
First, define a type for each subcommand, which has:
- An implementation: A function which is called when executing the subcommand.
- An optional command completion callback, which takes the lead of the
subcommand's arguments.
>lua
---@class FooSubcommand
---@field impl fun(args:string[], opts: table)
---@field complete? fun(subcmd_arg_lead: string): string[]
<
Next, we define a table mapping subcommands to their implementations and
completions:
>lua
---@type table<string, FooSubcommand>
local subcommand_tbl = {
action1 = {
impl = function(args, opts)
-- Implementation (args is a list of strings)
end,
-- This subcommand has no completions
},
action2 = {
impl = function(args, opts)
-- Implementation
end,
complete = function(subcmd_arg_lead)
-- Simplified example
local install_args = {
"first",
"second",
"third",
}
return vim.iter(install_args)
:filter(function(install_arg)
-- If the user has typed `:Foo action2 fi`,
-- this will match 'first'
return install_arg:find(subcmd_arg_lead) ~= nil
end)
:totable()
end,
-- ...
},
}
<
Then, create a Lua function to implement the main command:
>lua
---@param opts table :h lua-guide-commands-create
local function foo_cmd(opts)
local fargs = opts.fargs
local subcommand_key = fargs[1]
-- Get the subcommand's arguments, if any
local args = #fargs > 1 and vim.list_slice(fargs, 2, #fargs) or {}
local subcommand = subcommand_tbl[subcommand_key]
if not subcommand then
vim.notify("Foo: Unknown command: " .. subcommand_key, vim.log.levels.ERROR)
return
end
-- Invoke the subcommand
subcommand.impl(args, opts)
end
<
See also |lua-guide-commands-create|.
Finally, we register our command, along with the completions:
>lua
-- NOTE: the options will vary, based on your use case.
vim.api.nvim_create_user_command("Foo", foo_cmd, {
nargs = "+",
desc = "My awesome command with subcommand completions",
complete = function(arg_lead, cmdline, _)
-- Get the subcommand.
local subcmd_key, subcmd_arg_lead = cmdline:match("^Foo[!]*%s(%S+)%s(.*)$")
if subcmd_key
and subcmd_arg_lead
and subcommand_tbl[subcmd_key]
and subcommand_tbl[subcmd_key].complete
then
-- The subcommand has completions. Return them.
return subcommand_tbl[subcmd_key].complete(subcmd_arg_lead)
end
-- Check if cmdline is a subcommand
if cmdline:match("^Foo[!]*%s+%w*$") then
-- Filter subcommands that match
local subcommand_keys = vim.tbl_keys(subcommand_tbl)
return vim.iter(subcommand_keys)
:filter(function(key)
return key:find(arg_lead) ~= nil
end)
:totable()
end
end,
bang = true, -- If you want to support ! modifiers
})
<
==============================================================================
Keymaps *lua-plugin-keymaps*
Avoid creating keymaps automatically, unless they are not controversial. Doing
so can easily lead to conflicts with user |mapping|s.
NOTE: An example for uncontroversial keymaps are buffer-local |mapping|s for
specific file types or floating windows.
A common approach to allow keymap configuration is to define a declarative DSL
https://en.wikipedia.org/wiki/Domain-specific_language via a `setup` function.
However, doing so means that
- You will have to implement and document it yourself.
- Users will likely face inconsistencies if another plugin has a slightly
different DSL.
- |init.lua| scripts that call such a `setup` function may throw an error if
the plugin is not installed or disabled.
As an alternative, you can provide |<Plug>| mappings to allow users to define
their own keymaps with |vim.keymap.set()|.
- This requires one line of code in user configs.
- Even if your plugin is not installed or disabled, creating the keymap won't
throw an error.
Another option is to simply expose a Lua function or |user-commands|.
However, some benefits of |<Plug>| mappings over this are that you can
- Enforce options like `expr = true`.
- Expose functionality only for specific |map-modes|.
- Expose different behavior for different |map-modes| with a single |<Plug>|
mapping, without adding impurity or complexity to the underlying Lua
implementation.
NOTE: If you have a function that takes a large options table, creating lots
of |<Plug>| mappings to expose all of its uses could become
overwhelming. It may still be beneficial to create some for the most
common ones.
------------------------------------------------------------------------------
Example *lua-plugin-plug-mapping-example*
In your plugin:
>lua
vim.keymap.set("n", "<Plug>(SayHello)", function()
print("Hello from normal mode")
end, { noremap = true })
vim.keymap.set("v", "<Plug>(SayHello)", function()
print("Hello from visual mode")
end, { noremap = true })
<
In the user's config:
>lua
vim.keymap.set({"n", "v"}, "<leader>h", "<Plug>(SayHello)")
<
==============================================================================
Initialization *lua-plugin-initialization*
Newcomers to Lua plugin development will often put all initialization logic in
a single `setup` function, which takes a table of options.
If you do this, users will be forced to call this function in order to use
your plugin, even if they are happy with the default configuration.
Strictly separated configuration and smart initialization allow your plugin to
work out of the box.
NOTE: A well designed plugin has minimal impact on startup time.
See also |lua-plugin-lazy-loading|.
Common approaches to a strictly separated configuration are:
- A Lua function, e.g. `setup(opts)` or `configure(opts)`, which only overrides the
default configuration and does not contain any initialization logic.
- A Vimscript compatible table (e.g. in the |vim.g| or |vim.b| namespace) that your
plugin reads from and validates at initialization time.
See also |lua-vim-variables|.
Typically, automatic initialization logic is done in a |plugin| or |ftplugin|
script. See also |'runtimepath'|.
==============================================================================
Lazy loading *lua-plugin-lazy-loading*
When it comes to initializing your plugin, assume your users may not be using
a plugin manager that takes care of lazy loading for you.
Making sure your plugin does not unnecessarily impact startup time is your
responsibility. A plugin's functionality may evolve over time, potentially
leading to breakage if users have to hack into the loading mechanisms.
Furthermore, a plugin that implements its own lazy initialization properly will
likely have less overhead than the mechanisms used by a plugin manager or user
to load that plugin lazily.
------------------------------------------------------------------------------
Defer `require` calls *lua-plugin-lazy-loading-defer-require*
|plugin| scripts should not eagerly `require` Lua modules.
For example, instead of:
>lua
local foo = require("foo")
vim.api.nvim_create_user_command("MyCommand", function()
foo.do_something()
end, {
-- ...
})
<
which will eagerly load the `foo` module and any other modules it imports
eagerly, you can lazy load it by moving the `require` into the command's
implementation.
>lua
vim.api.nvim_create_user_command("MyCommand", function()
local foo = require("foo")
foo.do_something()
end, {
-- ...
})
<
NOTE: For a Vimscript alternative to `require`, see |autoload|.
NOTE: In case you are worried about eagerly creating user commands, autocommands
or keymaps at startup:
Plugin managers that provide abstractions for lazy-loading plugins on
such events will need to create these themselves.
------------------------------------------------------------------------------
Filetype-specific functionality *lua-plugin-lazy-loading-filetype*
Consider making use of |filetype| for any functionality that is specific to a
filetype, by putting the initialization logic in a `ftplugin/{filetype}.lua`
script.
------------------------------------------------------------------------------
Example *lua-plugin-lazy-loading-filetype-example*
A plugin tailored to Rust development might have initialization in
`ftplugin/rust.lua`:
>lua
if not vim.g.loaded_my_rust_plugin then
-- Initialize
end
-- NOTE: Using `vim.g.loaded_` prevents the plugin from initializing twice
-- and allows users to prevent plugins from loading
-- (in both Lua and Vimscript).
vim.g.loaded_my_rust_plugin = true
local bufnr = vim.api.nvim_get_current_buf()
-- do something specific to this buffer,
-- e.g. add a |<Plug>| mapping or create a command
vim.keymap.set("n", "<Plug>(MyPluginBufferAction)", function()
print("Hello")
end, { noremap = true, buffer = bufnr, })
<
==============================================================================
Configuration *lua-plugin-configuration*
Once you have merged the default configuration with the user's config, you
should validate configs.
Validations could include:
- Correct types, see |vim.validate()|
- Unknown fields in the user config (e.g. due to typos).
This can be tricky to implement, and may be better suited for a |health|
check, to reduce overhead.
==============================================================================
Troubleshooting *lua-plugin-troubleshooting*
------------------------------------------------------------------------------
Health checks *lua-plugin-troubleshooting-health*
Provide health checks in `lua/{plugin}/health.lua`.
Some things to validate:
- User configuration
- Proper initialization
- Presence of Lua dependencies (e.g. other plugins)
- Presence of external dependencies
See also |vim.health| and |health-dev|.
------------------------------------------------------------------------------
Minimal config template *lua-plugin-troubleshooting-minimal-config*
It can be useful to provide a template for a minimal configuration, along with
a guide on how to use it to reproduce issues.
==============================================================================
Versioning and releases *lua-plugin-versioning-releases*
Consider
- Using SemVer https://semver.org/ tags and releases to properly communicate
bug fixes, new features, and breaking changes.
- Automating versioning and releases in CI.
- Publishing to luarocks https://luarocks.org, especially if your plugin
has dependencies or components that need to be built; or if it could be a
dependency for another plugin.
------------------------------------------------------------------------------
Further reading *lua-plugin-versioning-releases-further-reading*
- Luarocks <3 Nvim https://github.com/nvim-neorocks/sample-luarocks-plugin
------------------------------------------------------------------------------
Tools *lua-plugin-versioning-releases-tools*
- luarocks-tag-release
https://github.com/marketplace/actions/luarocks-tag-release
- release-please-action
https://github.com/marketplace/actions/release-please-action
- semantic-release
https://github.com/semantic-release/semantic-release
==============================================================================
Documentation *lua-plugin-documentation*
Provide vimdoc (see |help-writing|), so that users can read your plugin's
documentation in Nvim, by entering `:h {plugin}` in |command-mode|.
------------------------------------------------------------------------------
Tools *lua-plugin-documentation-tools*
- panvimdoc https://github.com/kdheepak/panvimdoc
vim:tw=78:ts=8:sw=4:sts=4:et:ft=help:norl: