Eric's Blog 时光荏苒,岁月如梭

关于 Neovim 插件开发的指南

2024-07-29
Eric Wong

今天,在 Neovim 中文电报群有人分享了一个仓库 nvim-best-practices@24e835c, 是关于 Neovim 插件开发的一些“指南”,或者可以说是“建议”,当然我也回复了我的想法,抱歉,语言有些过激。

花花里胡巧的限制,我只看了开头就看不下去了,举例,命令要分成子命令,
这个除了浪费一层判断,没啥用。本来只定义一个FooInstall命令,
实际上在底层c就已经判断出来对应的函数。结果按照他这样,
需要在viml或者lua这边判断下到底这个命令是做什么的
跟谷歌的viml style guide相比差远了。另外手机上打开毫无阅读体验可谈。

起初本来不是特别在意,只在群里回复一下就算了,因为习惯每个人都有,只要 “can work” 就没必要纠结对与错了,因为毕竟也不会影响到自己的习惯。 但是随着浏览发现,居然要把这一内容合并到 Neovim 主仓库。 那我觉得就对我自己包括后来的 Neovim 用户都会有很大的影响。 因此我觉得有必要针对这个指南,写一些东西了。

逐行看一下,因为 Pull Request 的分支的一直在变动,而且是 force push,:(,因此原文在文章中有直接文字体现。

*lua-plugin.txt*                     Nvim

:h lua-plugin 很容易让人误解,也很难将内容与这个关键词联想在一起。 如果是针对于命令定义、按键映射定义的建议,那么就不局限于 Lua 了。 而且,这个不是某个 plugin 的文档,而是插件开发的建议,:h plugin-dev 或者 :h plugin-dev-guide 感觉更加合适一些。


                            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`

对于这点,我其实是持否定态度的。命令在定义时,其执行内容与字符串名字已经进行了绑定。 在调用命令时,底层 c 逻辑就已经判断出要去执行哪一个函数。 如果使用子命令,无疑要增加一个命令功能的分析,显得有些浪费。 如果一个插件只提拱了有限的几个命令, 比如:FooInstallFooUninstall,很明显就可以看出这两个命令的功能,根本没必要使用子命令。

当然,什么样情况下建议使用子命令呢?当多个命令的字面内容非常接近且难以区分时时,比如:

  • FooInstall1
  • FooInstall2

这种情况下,建议用子命令,比如 FooInstall 1FooInstall 2 或者,Foo install 1, Foo install 2


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

有点强推 Luarocks。


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

这些内容写在 wiki 或者一些单独的文章里足够了,合并到 Neovim 文档里面显得有些过了。并不是每个人都用 git 或者 github。


==============================================================================
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*

以上两个 tag 完全可以合并,每个内容太少了点,最后这一大段,看了满屏 tag。


- panvimdoc https://github.com/kdheepak/panvimdoc


延生阅读

分享到:

评论

目前只支持使用邮件参与评论。