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

从 VimScipt 切换至 Lua

2025-02-01
Eric Wong

Lua 在 Neovim 中已经被当作了一等公民,目前火热的 Neovim 插件、配置基本上都是使用 Lua 来开发的。而我也是从 2016 年就开始使用 Neovim 了。 这篇文章整理了一些从 VimScript 切换到 Lua 配置一些 Tips。

本文写作环境:

  • OS:Windows 11
  • Neovim:v0.10.0
  • Terminal: Windows Terminal

为什么选择 Lua

在前面我写过两篇文字,主要是来比较 Lua(luajit)与 VimScipt 以及 Vim9Script 的运行速度。

比较的结果显而易见,Luajit 的速度比起 VimScipt 以及 Vim9Script 快很多,而我的配置还是比较重的,因此选择 Lua 将会大大提升使用体验。

学习 Lua

Lua 是一门语法非常简单的编程语言,可以查看《学习 Lua 脚本语言》

配置文件结构

初始化文件

Neovim 的初始化文件是 ~\AppData\Local\nvim\init.lua,Linux 系统是 ~/.config/nvim/init.lua。Neovim 启动时会自动读取并执行该文件内容。

ftplugin

当打开一个文件,Neovim 通常会自动识别文件类型并且自动设定 filetype 。比如,打开 Test.java 文件,此时 ftplugin/java.lua 就会被调用执行。

Options

Neovim 提供了多种方式设置 Neovim Option。

使用 Neovim API:

Neovim 提供了设置 Option 的 API 函数:nvim_set_option_value({name}, {value}, {opts})opts 是一个 Lua table,支持的 key 包括:

  • scope:grobal 或者 local,类似于 :setglobal:setlocal
  • win: 设置指定 window-ID 的 local option
  • buf:设置指定 buffer number 的 local option

使用 vim.o

这类似于直接使用 :set 命令,默认是设置 global option。

vim.o.number = false         -- 禁用行号,启用行号可以设置为 true
vim.o.relativenumber = false -- 禁用相对行号

使用 vim.bovim.wo

这类似于使用 :setlocal 命令,但是还是有些区别的,按照 :h vim.bo 描述,其使用的格式是:vim.bo[{bufnr}].opt_name, 如果设置的 option 不是 local to buffer 就会报错,比如执行 :lua vim.bo.number = true 就会报错:

E5108: Error executing lua [string ":lua"]:1: 'buf' cannot be passed for window-local option 'number'
stack traceback:
	[C]: in function '__newindex'
	[string ":lua"]:1: in main chunk

同理,使用 vim.wo 时也会有类似的问题,因此在使用这两个方式设置 option 时,需要判断 option 是 local to window 还是 local to buffer, 一般在 help 文档里面都有。 比如 :h 'number':

'number' 'nu'		boolean	(default off)
			local to window

当然,Vim 也提供了函数去判断,nvim_get_option_info2({name}, {opts}) 返回值是一个 lua table,其中 scope key 值就可以用来判断,其值可以是 globalwinbuf

实际上,如果去看 Neovim 的源码,你会发现不管是 vim.o 还是 vim.bo 等 只不是 API 的 wrap 而已:

neovim’s options.lua#L242:

vim.o = setmetatable({}, {
  __index = function(_, k)
    return api.nvim_get_option_value(k, {})
  end,
  __newindex = function(_, k, v)
    return api.nvim_set_option_value(k, v, {})
  end,
})

因此,如果需要的话,也可以自己设置一个新的函数批量设置 option, 比如,某个插件打开了一个浮窗,浮窗内是对应的插件 buffer。

local function set_local(winid, bufnr, opts)
  for k, v in pairs(opts) do
    if vim.api.nvim_get_option_info2(k, {}).scope == 'win' then
      vim.api.nvim_set_option_value(k, v, { win = winid })
    elseif vim.api.nvim_get_option_info2(k, {}).scope == 'buf' then
      vim.api.nvim_set_option_value(k, v, { buf = bufnr })
    else
      -- skip global opt
    end
  end
end
local winid, bufnr = open_plugin_float_win()
set_local(winid, bufnr, {
  number = false,
  relativenumber = false,
  filetype = 'java',
  bufhidden = 'wipe',
})

事件自动命令

使用 VimScipt 定义自动命令(autocmd)的时候,通常我们可以这样写:

augroup test_augroup_vim
  autocmd!
  autocmd WinEnter * call s:test1()
  autocmd WinEnter * call s:test2()
augroup END

此时,如果需要清除上述两个自动命令的其中一个,就无法实现。只能使用如下脚本全部清除:

augroup text_augroup_vim
  autocmd!
augroup END

而 Neovim 提供了两个函数 nvim_create_autocmdnvim_del_autocmd,演示如下:

local function test1()
  print(1)
end
local function test2()
  print(2)
end
local group =
  vim.api.nvim_create_augroup('test_augroup_neovim', { clear = true })
local autocmd_id1 = vim.api.nvim_create_autocmd({ 'WinEnter' }, {
  group = group,
  pattern = { '*' },
  callback = test1,
})
local autocmd_id2 = vim.api.nvim_create_autocmd({ 'WinEnter' }, {
  group = group,
  pattern = { '*' },
  callback = test2,
})

此时如果需要删除第二个自动命令,只需要:

vim.api.nvim_del_autocmd(autocmd_id2)

用户自定义命令

可以使用 vim.api.nvim_create_user_command({name}, {command}, {opts}) API 来新建命令。

这个 API 函数接受三个参数,第一个顾名思义就是新建的命令的具体名称, 第二个 command 可以是一个 string 也可以是一个 lua function。如果是 string, 当执行这个命令时,这段字符串会被直接执行。如果是 Lua function,则会被传入一个 table 参数调用。 第三个参数是设定命令的一些参数,类似于 :h command-attributes

比如:

vim.api.nvim_create_user_command('SayHello', function(opt)
  print('hello')
end, {
  nargs = '*',
  bang = true,
})

在上述函数内,参数 opt 是一个 Lua table,key 值包括:

{ "bang", "reg", "range", "args", "mods", "line1", "smods", "fargs", "line2", "count", "name" }

bang:

如果定义命令时,第三个参数 bang = true,那么可以在执行命令时带上感叹号。

vim.api.nvim_create_user_command('Test', function(opt)
  print(opt.bang)
end, {
  nargs = '*',
  bang = true,
})

上述定义的命令,执行 :Test 时打印 false,执行 :Test! 时打印 true,也可以在函数内判断 if opt.bang 为命令是否带感叹号赋予不一样的意义。

有一些插件的命令支持两个感叹号,是如何实现的呢?比如 vim-gina

Single command. Users do not need to remember tons of commands

  • :Gina {command} will execute a gina command or a git raw command asynchronously
  • :Gina! {command} will execute a git raw command asynchronously
  • :Gina!! {command} will execute a git raw command in a shell (mainly for :Gina!! add -p or :Gina!! rebase -i)

下面是简单的实现代码:

vim.api.nvim_create_user_command('Gina', function(opt)
  if not opt.bang then
    print('it is Gina')
  elseif opt.bang and opt.fargs[1] == '!' then
    print('it is Gina!!')
  else
    print('it is Gina!')
  end
end, {
  nargs = '*',
  bang = true,
})

nargs 可以设置为 0、1、’*‘、’?’、’+’ 五种。

vim.api.nvim_create_user_command('Test1', function(opt)
end, {
  nargs = 0,
})
vim.api.nvim_create_user_command('Test2', function(opt)
  vim.print(opt.args)
  vim.print(opt.fargs)
end, {
  nargs = 1,
})
vim.api.nvim_create_user_command('Test3', function(opt)
  vim.print(opt.args)
  vim.print(opt.fargs)
end, {
  nargs = '*',
})
vim.api.nvim_create_user_command('Test4', function(opt)
  vim.print(opt.args)
  vim.print(opt.fargs)
end, {
  nargs = '+',
})
vim.api.nvim_create_user_command('Test5', function(opt)
  vim.print(opt.args)
  vim.print(opt.fargs)
end, {
  nargs = '?',
})

nargs 为 0,执行命令如果带上参数,那么就会报错。比如::Test1 foo 报错 E488: Trailing characters: foo

nargs 为 1 时,如果执行 :Test2 foo zaa xss 就会发现输出内容为:

foo zaa xss
{ "foo zaa xss" }

意味着,如果设置为 1,执行命令的后面一整串字符串不管是否有空格间隔,都被当作为一个参数。

nargs 为 * 时,如果执行 :Test3 foo zaa xss 就会发现输出内容为:

foo zaa xss
{ "foo", "zaa", "xss" }

可以看出,args 还是整个字符串,但是 fargs 则是一个列表,包含了三个参数

nargs 为 + 时,如果执行 :Test4 foo zaa xss 就会发现输出内容为:

foo zaa xss
{ "foo", "zaa", "xss" }

不同的是,如果执行 :Test4 不带参数,则会报错:E471: Argument required

nargs 为 ? 时,如果执行 :Test5 foo zaa xss 就会发现输出内容为:

foo zaa xss
{ "foo zaa xss" }

与设置成 1 不同的是,:Test5 可以不带参数直接执行

设置快捷键

Neovim 提供了 vim.keymap.set() 函数来设置快捷键, 如果阅读源码的话,也可以发现,实际上这个函数也不过是包装了 Neovim API 函数 vim.api.nvim_buf_set_keymapvim.api.nvim_set_keymap

但是原生的 API nvim_set_keymapnvim_buf_set_keymap 使用起来太麻烦了,对于一些特殊按键还需要使用 nvim_replace_termcodes() 去转换。

vim.keymap.set({mode}, {lhs}, {rhs}, {opts}) 的优势是:

differences compared to regular set_keymap:
- remap is used as opposite of noremap. By default it's true for <Plug> keymaps and false for others.
- rhs can be lua function.
- mode can be a list of modes.
- replace_keycodes option for lua function expr maps. (Default: true)
- handles buffer specific keymaps

这个 commit 移除了 <Plug> kemaps 检测, 这里是与 Vim 不一致的地方。

vim.keymap.set('i', '<Esc>', function()
  vim.cmd('noautocmd stopinsert')
  vim.api.nvim_win_close(prompt_winid, true)
  vim.api.nvim_win_close(result_winid, true)
end, { buffer = prompt_bufid })

变量类型转换

不管是从 Lua 到 VimScipt 还是从 VimScipt 到 Lua,变量类型转换是都是创建了一个副本,副本做的修改,通常无法在原变量上体现。 这也是我尽量避免使用 vim.fn 原因。

比如以下这段 VimScipt:

let s:a = [1, 2]
call add(s:a, 3)
echo s:a

以上输出是 [1, 2, 3]

换成 Lua:

local a = {1, 2}
vim.fn.add(a, 3)
vim.print(a) --- 输出是 {1, 2}

看一下 vim.fn 的源码:


-- vim.fn.{func}(...)
---@nodoc
vim.fn = setmetatable({}, {
  --- @param t table<string,function>
  --- @param key string
  --- @return function
  __index = function(t, key)
    local _fn --- @type function
    if vim.api[key] ~= nil then
      _fn = function()
        error(string.format('Tried to call API function with vim.fn: use vim.api.%s instead', key))
      end
    else
      _fn = function(...)
        return vim.call(key, ...)
      end
    end
    t[key] = _fn
    return _fn
  end,
})

实际上就是调用的 vim.call(key, ...)

因此应当尽量避免使用 vim.fn 调用 VimScipt 函数操作 Lua 变量,前面的示例应该改为:

local a = { 1, 2 }
table.insert(a, 3)
vim.print(a) --- 输出是 {1, 2, 3}

版权声明:本文为原创文章,遵循 署名-非商业性使用-禁止演绎 4.0 国际 (CC BY-NC-ND 4.0)版权协议,转载请附上原文出处链接和本声明。


延生阅读

分享到:

评论

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