年轻人的第一个nvim插件

工具 2024-07-27 11457 字 386 浏览 点赞

起步

去年七月,开发工具转至 nvim,同时写下《(不)习惯:向nvim的迁徙》,讲述自己拆解 IDE 需求,逐个击破重点难点,最后将“大迁徙”方案落地。一年以来,我优化了原有的 NvChad 配置,新写一些实用插件,将许多繁琐的操作浓缩进几个命令。当同事们还在界面上点来点去,我已经完成文件内容替换;当他们还在逐级进入文件夹,我已经把代码上传到了内部服务器。

使用一款自己可控的开发工具十分重要。如果你能写 JetBrains 系或是 VsCode 插件,后面的内容可以不用读。如果你写不了,我十分建议你转投 nvim 或 emacs。因为基于它们给自己写插件尤其简单,而你需要付出的,仅仅是记住一些快捷键。

本文旨在从零开始,教你写出属于自己的第一个 nvim 插件。当你发现原来写插件不过如此而已后,你在开发工具上的灵感将源源不断地喷涌而出。然后你会意识到,读完这篇文章,竟然只是长期快乐的开始。

我们要写什么样的插件

即将实现的这款插件,灵感来自于 emacs 的 M-x find-file(C-x C-f)。如果你是 emacs 用户,也许你会会心一笑,因为 find-file 是“神之编辑器”的入门功能。即使你只学过 15 分钟 emacs,你也会见到它。

执行 find-file,emacs 将展示当前打开文件所在目录下的所有内容。例如当前打开的文件路径是:~/.config/doom/customizations/init.el,则显示 ~/.config/doom/customizations/ 下的文件和目录。通过继续在 minibuffer 中键入字母,可以精确到某个文件,或是进入到指定子目录,再展示子目录下的内容。
image-20240721173816645

我很喜欢这个功能。有了它,不用打开目录树就能搜索到附近的文件,然后迅速打开。我将这个能力移植到了 nvim,成为最常使用的命令之一。

如何优雅地开始

网上大多教程是把插件写进 ~/.config/nvim 目录下。如果你希望写的插件别人也能使用,我们可以选择更优雅的方案。在你的工作目录下创建 nvim-config 目录,然后创建 nvim-config/init.lua 文件。

@guan ~/P/project$ tree nvim-config
nvim-config
└── init.lua

init.lua 中的内容如下:

print("hello world")

指定配置启动 nvim:nvim -u nvim-config/init.lua。你就能在左下角看到 “hello world”。

插件管理器(lazy)

我们需要一款插件管理器,用来加载自己写的插件。这里我选择 lazy.nvim,使用 Single File Setup 安装方式。

打开 nvim-config/init.lua,将配置粘贴进去,同时替换 data 和 config 目录。

-- 原:
-- local lazypath = vim.fn.stdpath("data") .. "/lazy/lazy.nvim"
-- 修改后:
local data_dir = vim.fn.expand("~") .. "/Public/project/nvim-config-data/"
local config_dir = vim.fn.expand("~") .. "/Public/project/nvim-config/"

local lazypath = data_dir .. "/lazy/lazy.nvim"
-- ignore partions
require("lazy").setup({
    root = data_dir .. "lazy",
    lockfile = config_dir .. "lazy-lock.json",
    spec = {
            -- ignore partions
    }
})

-- 关闭 insert mode 时的提示
vim.o.showmode = false

这样做的好处是,开发插件的配置 (nvim-config) 跟我们日常使用的 ~/.config/nvim 配置完全隔离,避免产生冲突。

创建插件

在 nvim-config 目录下创建插件目录 find-file.nvim,以及 init.lua 文件 (find-file.nvim/lua/find-file/init.lua)。此时你的目录结构应该是:

@guan ~/P/p/nvim-config$ tree find-file.nvim/
find-file.nvim/
└── lua
    └── find-file
        └── init.lua

find-file/init.lua:

local M = {}

function M.find_file_from_here()
    print("hello world")
end

return M

然后在 nvim-config/init.lua 中使用 lazy.nvim 加载你的插件。同时创建 FindFile 命令。

-- Setup lazy.nvim
require("lazy").setup({
    spec = {
        -- import your plugins
        -- { import = "plugins" },
        -- 加载插件配置
        {
            dir = "~/Public/project/nvim-config/find-file.nvim",
            config = function()
                vim.api.nvim_create_user_command(
                    "FindFile",
                    'lua require("find-file").find_file_from_here()',
                    { bang = true }
                )
            end,
        },
    },
    -- Configure any other settings here. See the documentation for more details.
    -- colorscheme that will be used when installing plugins.
    install = { colorscheme = { "habamax" } },
    -- automatically check for plugin updates
    checker = { enabled = true },
})

退出 nvim,通过 nvim -u init.lua 重新打开 nvim,执行 :FindFile,就能看到左下角的 “hello world”。

到此为止,我们已经完成了实现一款插件的前期准备工作,剩下的只不过是填充业务逻辑。

Rg与Telescope.nvim

在 linux 上搜索文件可供使用的命令很多,如原生的 find、grep;热门的 fzf、fd。这里我选择 ripgrep。NvChad 提供的文件搜索就是基于 ripgrep 实现,我选择 rg 也是为了方便抄代码。请记住,模仿是最快的学习方法。

直接上命令公式:

rg --files --color never -- [prompt] [path-to-directory]

rg 教程很多,不再累述。我们仅需要知道,如果我们想搜索 a 目录,那么 [path-to-directory] = ./a;如果想搜索 a/c 目录,[path-to-directory] = ./a/c。[prompt] 保持为空字符串,我们后面会用到。举个例:

rg --files --color never -- '' "./a/b"

有了搜索结果,还需要在 nvim 上展示出来,我是用 telescope.nvim。如仓库介绍,集 Find、Filter、Preview、Picker 为一身。我见过有大佬嫌 telescope 太慢,用 vim 自己的 Quickfix 展示结果。还有一个酷炫的 quickfix window 项目:nvim-bqf。总之等你熟练写插件以后,可以做自己的主,而当下,请先用 telescope.nvim。

lazy.nvim 可以帮我们管理插件依赖:

{
    dir = "~/Public/project/nvim-config/find-file.nvim",
    dependencies = { "nvim-telescope/telescope.nvim" },  -- add this line
}

保存配置再重新打开 nvim,插件会自动安装好。

实现业务逻辑

说回 find-file/init.lua 文件。在 lua 中,将一个模块里的函数供给其他模块调用,需要把函数 return 出去。所以每个 lua 文件都应该有这样的样板代码:

local M = {}
-- 定义函数
function M.your_function()
end
-- 导出函数
return M  

我们先实现一个 hello world 函数:

local M = {}

function M.hello_world()
    print("hello world")
end

return M

在插件管理器中绑定命令:

{
    dir = "~/Public/project/nvim-config/find-file.nvim",
    dependencies = { "nvim-telescope/telescope.nvim" },
    config = function()  
        -- binding command
        vim.api.nvim_create_user_command(
            "HelloWorld",
            'lua require("find-file").hello_world()',
            { bang = true }
        )
    end,
}

重新启动 nvim,执行 :HelloWorld 就能看到左下角的 “hello world”。

先定义 M.find_file_from_here() 函数。接下来就是如何展示搜索结果,以下这段代码也是从 NvChad 中摘出来的:

local pickers = require("telescope.pickers")
local finders = require("telescope.finders")
local make_entry = require("telescope.make_entry")
local sorters = require("telescope.sorters")
local conf = require("telescope.config").values

function M.find_file_from_here()
    -- live_grepper 搜索回调函数
    local live_grepper = finders.new_job(function(prompt)
        local rg_command = ...
        return rg_command
    end, make_entry.gen_from_file())
    -- 使用 picker 显示搜索结果
    local opt = {}
    pickers
        .new(opt, {
            prompt_title = "Find File From Here",
            __locations_input = true,
            finder = live_grepper,
            previewer = conf.grep_previewer(opt),
            sorter = sorters.get_fuzzy_file(opt),
            default_text = ...,  -- 首次搜索内容
        })
        :find()
end

逻辑很简单,只是需要了解 telescope api 的用法。我们这次关心的只有两个参数,其他照着抄下来就好。一个是搜索时的回调参数 finder,另一个是首次唤起 picker 时,使用的搜索参数 default_text。

我们想要 emacs 的效果,首次唤起 picker 展示当前目录下的文件,default_text 的入参就得是当前目录的路径:

local utils = require("find-file.utils")

function M.find_file_from_here()
    local files_from_here = utils.get_cur_buf_dir()
    
    local opt = {}
    pickers
        .new(opt, {
            -- ignore others
            default_text = files_from_here,
        })
        :find()

end

find-file/utils.lua 存放了公共函数,已经上传到 github 。今后你攒 nvim 配置途中也会收集许多有用函数,当下可以先从我的配置抄起来。

finder 入参对应 live_grepper,我们先写个最简版。

local live_grepper = finders.new_job(function(prompt)
    prompt = vim.fn.trim(prompt)

    local dir, prompt = prompt, ""
    return utils.flatten({ { "rg", "--files", "--color", "never" }, "--", prompt, dir })
end, make_entry.gen_from_file())

telescope 中,输入框的内容通过 prompt 回传。我们根据前面提到的 rg 公式构建命令:rg --files --color never -- [prompt] [path-to-directory]。find-file 是希望展示某目录下的所有文件,回传的 prompt 对应的是一个路径,跟我们命令中的 [prompt] 语义不同,跟 [path-to-directory] 语义相同,因此要把 prompt 的值赋给 dir,同时给 prompt 赋一个空字符。此时执行 :FindFile,就能看到当前目录下的文件了。
image-20240727183850547

现在的 find-file 有一个小 bug,输入框中是 “find-file.nvim/lua” 时,会展示 lua 目录下的文件;但如果输入框中是 “find-file.nvim/lu”,什么也不展示。此时对应的命令为:

rg --files --color never -- '' 'find-file.nvim/lu'

很明显,我们没有 find-file.nvim/lu 目录,什么都不展示理所当然,可用起来很不爽。解决的办法是,如果发现回传的 prompt 不是一个目录,就取 “/” 之前的内容作为目录([path-to-directory]),“/” 之后的内容作为 [prompt]。写成代码就是下面这样:

local live_grepper = finders.new_job(function(prompt)
    prompt = vim.fn.trim(prompt)

    local dir = nil
    dir, prompt = prompt, ""
    
    local is_dir = vim.fn.isdirectory(dir)
    if is_dir == 0 then
        local chunks = utils.split(dir, "/")
        prompt = chunks[vim.fn.len(chunks)]
        chunks[vim.fn.len(chunks)] = ""
        dir = utils.join(chunks, "/")
    end

    return utils.flatten({ { "rg", "--files", "--color", "never" }, "--", prompt, dir })
end, make_entry.gen_from_file())

如果输入框是 “find-file.nvim/lu”,生成的命令就会是:

rg --files --color never -- 'lu' 'find-file.nvim'

image-20240727185515559

我们使用了 telescope 的模糊搜索能力,因此 find-file.nvim/readme.md 也会作为搜索结果出现。个人觉得影响不大,不用修复这个问题。

优化体验

现在的 find-file 会展示指定目录下的所有文件(递归展示),遇到文件很多的目录,搜索结果会很多,使用体验变差。最好是增加二级搜索的能力──这个 idea 也是我从 emacs 周边学来的(笑)。

即,搜索框支持:[path-to-directory] [prompt]。例如,搜索框中的内容为 “find-file.nvim init”,生成对应的 rg 命令:

rg --files --color never -- 'init' 'find-file.nvim'

image-20240727190850305

实现起来也简单,检查回传的 prompt 中没有空格,有就 split 一下。具体代码如下:

local live_grepper = finders.new_job(function(prompt)
    prompt = vim.fn.trim(prompt)

    local dir = nil
    -- 检查回传 prompt 中有没有空格
    if string.match(prompt, "%s+") ~= nil then
        local chunks = utils.splitn(prompt, " ", 2)
        if vim.fn.len(chunks) >= 2 then
            dir, prompt = vim.fn.trim(chunks[1]), vim.fn.trim(chunks[2])
        else
            dir, prompt = prompt, ""
        end
    else
        dir, prompt = prompt, ""
    end

    local is_dir = vim.fn.isdirectory(dir)
    if is_dir == 0 then
        local chunks = utils.split(dir, "/")
        prompt = chunks[vim.fn.len(chunks)]
        chunks[vim.fn.len(chunks)] = ""
        dir = utils.join(chunks, "/")
    end

    return utils.flatten({ { "rg", "--files", "--color", "never" }, "--", prompt, dir })
end, make_entry.gen_from_file())

创建快捷键

每次敲 :FindFile 挺麻烦的,我对这种常用功能更偏爱绑快捷键。

{
    dir = "~/Public/project/nvim-config/find-file.nvim",
    dependencies = { "nvim-telescope/telescope.nvim" },
    config = function()
        vim.api.nvim_create_user_command(
            "FindFile",
            'lua require("find-file").find_file_from_here()',
            { bang = true }
        )
        -- 绑定快捷键
        vim.keymap.set("n", "<leader><space>", require("find-file").find_file_from_here)
    end,
}

现在连敲两次空格就能弹出搜索窗了。

最后

插件写好以后,希望分享给别人使用,当然是上传 github 啦!这篇文章的完整代码你可以在 find-file.nvim 看到。在 lazy.nvim 使用 github 上的插件,只需要把 “dir = ...” 删掉,使用仓库路径即可。

{
    "youguanxinqing/find-file.nvim",  -- github 仓库路径
    dependencies = { "nvim-telescope/telescope.nvim" },
    config = function()
        -- binding command `:FindFile`
        vim.api.nvim_create_user_command(
            "FindFile",
            'lua require("find-file").find_file_from_here()',
            { bang = true }
        )
        -- binding shortcut
        vim.keymap.set("n", "<leader><space>", require("find-file").find_file_from_here)
    end,
}

在 nvim 中要怎么 debug 呢?使用万能的 print 函数就好。遇到 table 类型时,先 vim.inspect 一下。

print(vim.inspect(one_table))

感谢



本文由 Guan 创作,采用 知识共享署名 3.0,可自由转载、引用,但需署名作者且注明文章出处。

还不快抢沙发

添加新评论