在 nvim 中使用 rime-ls 配置中文输入法

前言

在使用 nvim 的过程中,我们往往需要写一些文档性质的文件,而很多文档性质的文件需要中英文混合输入, 而如果我们使用系统的输入法进行操作会面临频繁切换中英文输入模式, 或者需要频繁地使用回车或者上档键将输入法中的英文字符上屏。

如果可以直接在 nvim 中使用输入法,那么将会大幅度提升我们的体验。在完成了相关的设置之后, 当我使用这套配置写下这篇文章的时候,总体的体验还是非常不错的。

本文将会介绍如何使用 blink-cmp + rime-ls 完成 nvim 中实现中英文混合输入,以及过程中遇到的问题和解决思路。

安装 rime-ls

rime-ls 是一个 LSP,可以帮助我们在 nvim 中配合补全插件使用 rime 输入法。

rime-lsGitHub 上有编译好的二进制文件,直接在 release 页面下载对应的版本然后放在环境变量中就可以了。

如果是使用 Arch 可以直接通过 yay -Sy --noconfirm rime-ls 来安装作者提供的 AUR 包。

NOTE:还需要安装 blink-cmplspconfig 用于配置 rime-ls。 由于这几个是非常常用的插件,这里不详细介绍如何安装。

rime-ls 配置

如果你查看了 rime-ls 的官方文档,你会获得一份完成了大部分设置的配置。 为了更好的进行配置管理,我创建了一个 rime_ls.lua 文件:

local function contains_unacceptable_character(content)
    if content == nil then return true end
    local ignored_head_number = false
    for i = 1, #content do
        local b = string.byte(content, i)
        if b >= 48 and b <= 57 or b == 32 or b == 46 then
            -- number dot and space
            if ignored_head_number then
                return true
            end
        elseif b <= 127 then
            return true
        else
            ignored_head_number = true
        end
    end
    return false
end

local M = {}

function M.is_rime_item(item)
    if item == nil or item.source_name ~= 'LSP' then return false end
    local client = vim.lsp.get_client_by_id(item.client_id)
    return client ~= nil and client.name == 'rime_ls'
end

function M.rime_item_acceptable(item)
    return
        not contains_unacceptable_character(item.label)
        or
        item.label:match("%d%d%d%d%-%d%d%-%d%d %d%d:%d%d:%d%d%")
end

function M.rime_status_icon()
    return vim.g.rime_enabled and 'ㄓ' or ''
end

function M.get_n_rime_item_index(n, items)
    if items == nil then
        items = require('blink.cmp.completion.list').items
    end
    local result = {}
    if items == nil or #items == 0 then
        return result
    end
    for i, item in ipairs(items) do
        if M.is_rime_item(item) and M.rime_item_acceptable(item) then
            result[#result + 1] = i
            if #result == n then
                break;
            end
        end
    end
    return result
end

--- @class RimeSetupOpts
--- @field filetype string|string[]

--- @param opts RimeSetupOpts
function M.setup(opts)
    opts.filetypes = opts and opts.filetypes or { '*' }
    if type(opts.filetypes) == 'string' then
        opts.filetypes = { opts.filetypes }
    end
    local configs = require('lspconfig.configs')
    vim.g.rime_enabled = false
    configs.rime_ls = {
        default_config = {
            name = "rime_ls",
            cmd = { vim.fn.expand '~/rime-ls/target/release/rime_ls' },
            filetypes = opts.filetype,
            single_file_support = true,
        },
        settings = {},
    }
end

return M

这样我便可以像使用一个插件一样使用 rime-ls 了。接下来,我在 lsp_config.lua 中加载了这个配置, 这里以 blink-cmp 为例:

require('plugins.rime_ls').setup({
    filetype = vim.g.rime_ls_support_filetype,
})
local lspconfig = require('lspconfig')
local lsp_capabilities = vim.lsp.protocol.make_client_capabilities()
lsp_capabilities = require('blink.cmp').get_lsp_capabilities(lsp_capabilities)
lspconfig.rime_ls.setup({
    init_options = {
        enabled = vim.g.rime_enabled,
        shared_data_dir = '/usr/share/rime-data',
        user_data_dir = vim.fn.expand('~/.local/share/rime-ls'),
        log_dir = vim.fn.expand('~/.local/share/rime-ls'),
        always_incomplete = true, -- This is for wubi users
        long_filter_text = true
    },
    capabilities = lsp_capabilities,
    on_attach = rime_on_attach
})

你如果需要添加其他配置选项,可以参考 rime-ls 的官方文档。

这里讲一下如何配置 rime-ls。在上面的代码中, 存在 user_data_dir = vim.fn.expand('~/.local/share/rime-ls') 因此配置文件需要放置在这个目录下, 而配置方法与配置 rime 输入法是一致的,将你的方案放置在 ~/.local/share/rime-ls 目录下即可。

数字键自动上屏

blink-cmp 的用户要绑定上屏操作是比较简单的:

-- When there is only one rime item after inputting a number, select it directly
require('blink.cmp.completion.list').show_emitter:on(function(event)
    if not vim.g.rime_enabled then return end
    local col = vim.fn.col('.') - 1
    if event.context.line:sub(col, col):match('%d') == nil then return end
    local rime_item_index = require('plugins.rime_ls').get_n_rime_item_index(2, event.items)
    if #rime_item_index ~= 1 then return end
    vim.schedule(function() require('blink.cmp').accept({ index = rime_item_index[1] }) end)
end)

一二三候选

-- NOTE: put those below to blink.cmp keymap setting
-- select first one directly
['<space>'] = {
    function(cmp)
        if not vim.g.rime_enabled then return false end
        local rime_item_index = require('plugins.rime_ls').get_n_rime_item_index(1)
        if #rime_item_index ~= 1 then return false end
        -- If you want to select more than once,
        -- just update this cmp.accept with vim.api.nvim_feedkeys('1', 'n', true)
        -- The rest can be updated similarly
        return cmp.accept({ index = rime_item_index[1] })
    end,
    'fallback' },
-- select second one directly
[';'] = {
    -- FIX: can not work when binding ; as a prefix key
    function(cmp)
        if not vim.g.rime_enabled then return false end
        local rime_item_index = require('plugins.rime_ls').get_n_rime_item_index(2)
        if #rime_item_index ~= 2 then return false end
        return cmp.accept({ index = rime_item_index[2] })
    end, 'fallback' },
-- select third one directly
['\''] = {
    function(cmp)
        if not vim.g.rime_enabled then return false end
        local rime_item_index = require('plugins.rime_ls').get_n_rime_item_index(3)
        if #rime_item_index ~= 3 then return false end
        return cmp.accept({ index = rime_item_index[3] })
    end, 'fallback' }

上面的操作不会影响按键的原始功能,例如对于一些 auto-pairs 插件, <space> 依然可以在括号中插入两个空格。

中文标点

中文标点的使用一直是一个痛点,而如果使用 rime-ls 进行标点的频繁切换的话又会影响工作的效率, 为了解决这个问题,我想到了一个简单的方法。这个方法的灵感来源于我平常写英文文档的习惯。众所周知, 在书写英文的时候, 如果输入了一个标点符号后,我们是需要输入一个空格与下一个英文隔开的, 因为英文的标点只占用一个宽度,如果不隔开会导致非常的拥挤。 正是因为这个习惯我想到了可以在 rime-ls 启动的时候通过标点加空格的形式插入中文标点, 而在输入法关闭的时候,标点加空格则是正常的添加英文标点与空格。 于是有了以下的代码 (如果使用这种方式的话,需要将 rime 标点输入设置为英文标点):

local mapped_punc = {
    -- Add more punctuations if you like
    [','] = ',',
    ['.'] = '。',
    [':'] = ':',
    ['?'] = '?',
    ['\\'] = '、'
    -- FIX: can not work now
    -- [';'] = ';',
}
-- Toggle rime
-- This will toggle Chinese punctuations too
map_set({ 'n', 'i' }, '<c-space>', function()
    -- We must check the status before the toggle
    if vim.g.rime_enabled then
        for k, _ in pairs(mapped_punc) do
            pcall(map_del, { 'i' }, k .. '<space>')
        end
    else
        for k, v in pairs(mapped_punc) do
            map_set({ 'i' }, k .. '<space>', function()
                if utils.rime_ls_disabled({ line = vim.api.nvim_get_current_line(),
                        cursor = vim.api.nvim_win_get_cursor(0) }) then
                    feedkeys(k .. '<space>', 'n')
                else
                    feedkeys(v, 'n')
                end
            end)
        end
    end
    vim.cmd('RimeToggle')
end)

自动开关 rime-ls

在写 markdown 的时候,我是不会在一些符号中使用中文的,所以我写了这个功能。

这一部分我只在 blink-cmp 中进行了实现,但是实现方法是一致的:

-- When input method is enabled, disable the following patterns
vim.g.disable_rime_ls_pattern = {
    -- disable in ``
    '`(.-)`',
    -- disable in ''
    '\'(.-)\'',
    -- disable in ""
    '"(.-)"',
    -- disable in []
    '%[.-%]',
}
--- context.line will get current line
--- context.cursor will get the cursor position
--- Return if rime_ls should be disabled in current context
function M.rime_ls_disabled(context)
    local line = context.line
    local cursor_column = context.cursor[2]
    for _, pattern in ipairs(vim.g.disable_rime_ls_pattern) do
        local start_pos = 1
        while true do
            local match_start, match_end = string.find(line, pattern, start_pos)
            if not match_start then
                break
            end
            if cursor_column >= match_start and cursor_column < match_end then
                return true
            end
            start_pos = match_end + 1
        end
    end
    return false
end

这样只需要在相关位置添加一次判断,对于 blink-cmp 我们可以在 transform_items 中添加:

--- @param context blink.cmp.Context
--- @param items blink.cmp.CompletionItem[]
transform_items = function(context, items)
    local TYPE_ALIAS = require('blink.cmp.types').CompletionItemKind
    -- demote snippets
    for _, item in ipairs(items) do
        if item.kind == TYPE_ALIAS.Snippet then
            item.score_offset = item.score_offset - 3
        end
    end
    -- filter non-acceptable rime items
    --- @param item blink.cmp.CompletionItem
    return vim.tbl_filter(function(item)
        local rime_ls = require('plugins.rime_ls')
        if not rime_ls.is_rime_item(item) and
            item.kind ~= TYPE_ALIAS.Text then
            return true
        end
        if require('utils').rime_ls_disabled(context) then
            return false
        end
        item.detail = nil
        return rime_ls.rime_item_acceptable(item)
    end, items)
end

copilot-chatrime-ls

在我完成了所有配置后,我发现在 copilot-chat 的窗口中不能在触发 rime-ls 的补全, 在我提了 issue 后我根据网友的回答得到了以下的代码:

vim.api.nvim_create_autocmd('FileType', {
    pattern = rime_ls_filetypes,
    callback = function (env)
        local rime_ls_client = vim.lsp.get_clients({ name = 'rime_ls' })
        -- 如果没有启动 `rime-ls` 就手动启动
        if #rime_ls_client == 0 then
            vim.cmd('LspStart rime_ls')
            rime_ls_client = vim.lsp.get_clients({ name = 'rime_ls' })
        end
        -- `attach` 到 `buffer`
        if #rime_ls_client > 0 then
            vim.lsp.buf_attach_client(env.buf, rime_ls_client[1].id)
        end
    end
})

我估计这个不能够自动启动的原因是 copilot-chat 窗口并不是一个有效的 buffer, 而通过上面的方式启动后会创建一个新的 buffer 这个 buffercopilot-chat 中的内容相同。

最后

完整的配置:rime_lsblink-cmp

后续会有新的更改:dotfiles




    Enjoy Reading This Article?

    Here are some more articles you might like to read next:

  • 客制化 Neovim
  • 鞋带公式
  • 背包问题
  • 中国剩余定理及其扩展
  • 模逆元