为 Swift 开发配置 Neovim

Neovim 是流行的基于终端的文本编辑器 Vim 的现代重新实现。除了 Vim 为原始 Vi 编辑器带来的改进之外,Neovim 还添加了异步操作和强大的 Lua 绑定等新功能,以实现流畅的编辑体验。

本文将引导您完成为 Swift 开发配置 Neovim 的过程,提供各种插件的配置,以构建可用的 Swift 编辑体验。配置文件是逐步构建的,文章末尾包含这些文件的完整版本。这不是关于如何使用 Neovim 的教程,并且假定您熟悉像 NeovimVimVi 这样的模态文本编辑器。我们还假设您已经在您的计算机上安装了 Swift 工具链。如果还没有,请参阅 Swift 安装说明

尽管本文引用了 Ubuntu 22.04,但配置本身适用于任何可以获得最新版本的 Neovim 和 Swift 工具链的操作系统。

基本设置和配置包括

  1. 安装 Neovim。
  2. 安装 lazy.nvim 以管理我们的插件。
  3. 配置 SourceKit-LSP 服务器。
  4. 使用 nvim-cmp 设置由语言服务器驱动的代码补全。
  5. 使用 LuaSnip 设置代码片段。

提供以下部分以帮助指导您完成设置

提示:如果您已经安装了 Neovim、Swift 和包管理器,您可以跳过设置 语言服务器支持

注意:如果您绕过 先决条件 部分,请确保您的 Neovim 版本为 v0.9.4 或更高版本,否则您可能会遇到一些语言服务器协议 (LSP) Lua API 的问题。

先决条件

要开始使用,您需要安装 Neovim。Neovim 公开的 Lua API 正在快速开发中。我们将要利用语言服务器协议 (LSP) 集成支持的最新改进,因此我们需要相当新的 Neovim 版本。

我正在 x86_64 机器上运行 Ubuntu 22.04。不幸的是,Ubuntu 22.04 apt 存储库中提供的 Neovim 版本太旧,无法支持我们将要使用的许多 API。

对于此安装,我使用 snap 安装了 Neovim v0.9.4。Ubuntu 24.04 具有足够新的 Neovim 版本,因此正常的 apt install neovim 调用将起作用。有关在其他操作系统和 Linux 发行版上安装 Neovim 的信息,请参阅 Neovim 安装页面

 $  sudo snap install nvim --classic
 $  nvim --version
NVIM v0.9.4
Build type: RelWithDebInfo
LuaJIT 2.1.1692716794
Compilation: /usr/bin/cc -O2 -g -Og -g -Wall -Wextra -pedantic -Wno-unused-pa...

   system vimrc file: "$VIM/sysinit.vim"
  fall-back for $VIM: "/usr/share/nvim"

Run :checkhealth for more info

开始使用

我们在路径中拥有 Neovim 和 Swift 的工作副本。虽然我们可以从 vimrc 文件开始,但 Neovim 正在从使用 vimscript 过渡到 Lua。Lua 更容易找到文档,因为它是一种真正的编程语言,运行速度更快,并将您的配置从主运行循环中拉出来,因此您的编辑器保持良好和流畅。您仍然可以将 vimrc 与 vimscript 一起使用,但我们将使用 Lua。

主要的 Neovim 配置文件位于 ~/.config/nvim 中。其他 Lua 文件位于 ~/.config/nvim/lua 中。现在继续创建一个 init.lua

 $  mkdir -p ~/.config/nvim/lua && cd ~/.config/nvim
 $  nvim init.lua

注意:以下示例包含插件的 GitHub 链接,以帮助您轻松访问文档。您还可以探索插件本身。

使用 lazy.nvim 打包

虽然可以手动设置所有内容,但使用包管理器有助于使您的包保持最新,并确保在将配置复制到新计算机时一切都正确安装。Neovim 也有一个内置的插件管理器,但我发现 lazy.nvim 工作良好。

我们将从一个小的引导脚本开始,以在尚未安装 lazy.nvim 的情况下安装它,将其添加到我们的运行时路径,最后配置我们的软件包。

在您的 init.lua 顶部写入

local lazypath = vim.fn.stdpath("data") .. "/lazy/lazy.nvim"
if not vim.loop.fs_stat(lazypath) then
    vim.fn.system({
        "git",
        "clone",
        "--filter=blob:none",
        "https://github.com/folke/lazy.nvim.git",
        "--branch=stable",
        lazypath
    })
end
vim.opt.rtp:prepend(lazypath)

此代码段在 lazy.nvim 尚不存在时克隆它,然后将其添加到运行时路径。现在我们初始化 lazy.nvim 并告诉它在哪里查找插件规范。

require("lazy").setup("plugins")

这会将 lazy.nvim 配置为在我们的 lua/ 目录下的 plugins/ 目录中查找每个插件。我们还需要一个地方来放置我们自己的非插件相关配置,因此我们将它放在 config/ 中。现在继续创建这些目录。

 $  mkdir lua/plugins lua/config

有关配置 lazy.nvim 的详细信息,请参阅 lazy.nvim 配置

_lazy.nvim_ package manger

请注意,您的配置看起来不会完全像这样。我们只安装了 lazy.nvim,所以目前只有该插件列在您的配置中。这看起来不是很令人兴奋,所以我添加了一些额外的插件,使其看起来更具吸引力。

要检查它是否工作

语言服务器支持

语言服务器响应编辑器请求,提供特定于语言的支持。Neovim 内置了对语言服务器协议 (LSP) 的支持,因此您不需要外部包来支持 LSP,但是手动为每个 LSP 服务器添加配置会做很多工作。Neovim 有一个用于配置 LSP 服务器的包,nvim-lspconfig

继续在 lua/plugins/lsp.lua 下创建一个新文件。在其中,我们将从添加以下代码段开始。

return {
    {
        "neovim/nvim-lspconfig",
        config = function()
            local lspconfig = require('lspconfig')
            lspconfig.sourcekit.setup {}
        end,
    }
}

虽然这通过 SourceKit-LSP 为我们提供了 LSP 支持,但没有键绑定,因此不是很实用。现在让我们连接这些键绑定。

我们将设置一个自动命令,该命令在 sourcekit 服务器设置下的 config 函数中附加 LSP 服务器时触发。键绑定应用于所有 LSP 服务器,因此您最终获得跨语言的一致体验。

config = function()
    local lspconfig = require('lspconfig')
    lspconfig.sourcekit.setup {}

    vim.api.nvim_create_autocmd('LspAttach', {
        desc = 'LSP Actions',
        callback = function(args)
            vim.keymap.set('n', 'K', vim.lsp.buf.hover, {noremap = true, silent = true})
            vim.keymap.set('n', 'gd', vim.lsp.buf.definition, {noremap = true, silent = true})
        end,
    })
end,

LSP powered live error messages

我创建了一个小的 Swift 包示例,该包异步计算 斐波那契数。在对 fibonacci 函数的引用之一上按 shift + k 会显示该函数的文档以及函数签名。LSP 集成也显示我们的代码中存在错误。

文件更新

SourceKit-LSP 越来越依赖于编辑器在某些文件更改时通知服务器。这种需求通过动态注册进行通信。您不必理解这意味着什么,但 Neovim 没有实现动态注册。当您更新包清单或向 compile_commands.json 文件添加新文件,并且 LSP 在不重启 Neovim 的情况下不起作用时,您会注意到这一点。

相反,我们知道 SourceKit-LSP 需要此功能,因此我们将静态启用它。我们将更新我们的 sourcekit 设置配置以手动设置 didChangeWatchedFiles 功能。

lspconfig.sourcekit.setup {
    capabilities = {
        workspace = {
            didChangeWatchedFiles = {
                dynamicRegistration = true,
            },
        },
    },
}

如果您有兴趣阅读有关此问题的更多信息,以下问题中的对话更详细地描述了该问题

代码补全

LSP-driven autocomplete completing the Foundation module

我们将使用 nvim-cmp 作为代码补全机制。我们将首先告诉 lazy.nvim 下载包并在我们进入插入模式时延迟加载它,因为如果您不编辑文件,则不需要代码补全。

-- lua/plugins/codecompletion.lua
return {
    {
        "hrsh7th/nvim-cmp",
        version = false,
        event = "InsertEnter",
    },
}

接下来,我们将配置一些补全源以提供代码补全结果。nvim-cmp 不附带补全源,这些是额外的插件。对于此配置,我想要基于 LSP、文件路径补全和当前缓冲区中的文本的结果。有关更多信息,nvim-cmp Wiki 有一个 源列表

首先,我们将告诉 lazy.nvim 关于新插件以及 nvim-cmp 依赖于它们。这确保了 lazy.nvim 将在加载 nvim-cmp 时初始化每个插件。

-- lua/plugins/codecompletion.lua
return {
    {
        "hrsh7th/nvim-cmp",
        version = false,
        event = "InsertEnter",
        dependencies = {
            "hrsh7th/cmp-nvim-lsp",
            "hrsh7th/cmp-path",
            "hrsh7th/cmp-buffer",
        },
    },
    { "hrsh7th/cmp-nvim-lsp", lazy = true },
    { "hrsh7th/cmp-path", lazy = true },
    { "hrsh7th/cmp-buffer", lazy = true },
}

现在我们需要配置 nvim-cmp 以利用代码补全源。与许多其他插件不同,nvim-cmp 隐藏了许多内部工作原理,因此配置它与其他插件略有不同。具体来说,您会注意到围绕设置键绑定的差异。我们首先从其自身的配置函数中需要模块,并将显式调用 setup 函数。

{
    "hrsh7th/nvim-cmp",
    version = false,
    event = "InsertEnter",
    dependencies = {
        "hrsh7th/cmp-nvim-lsp",
        "hrsh7th/cmp-path",
        "hrsh7th/cmp-buffer",
    },
    config = function()
        local cmp = require('cmp')
        local opts = {
            -- Where to get completion results from
            sources = cmp.config.sources {
                { name = "nvim_lsp" },
                { name = "buffer"},
                { name = "path" },
            },
            -- Make 'enter' key select the completion
            mapping = cmp.mapping.preset.insert({
                ["<CR>"] = cmp.mapping.confirm({ select = true })
            }),
        }
        cmp.setup(opts)
    end,
},

使用 tab 键选择补全是一个相当流行的选项,所以我们现在继续设置它。

mapping = cmp.mapping.preset.insert({
    ["<CR>"] = cmp.mapping.confirm({ select = true }),
    ["<tab>"] = cmp.mapping(function(original)
        if cmp.visible() then
            cmp.select_next_item() -- run completion selection if completing
        else
            original()      -- run the original behavior if not completing
        end
    end, {"i", "s"}),
    ["<S-tab>"] = cmp.mapping(function(original)
        if cmp.visible() then
            cmp.select_prev_item()
        else
            original()
        end
    end, {"i", "s"}),
}),

在补全菜单可见时按 tab 将选择下一个补全,shift + tab 将选择上一个项目。如果菜单不可见,则制表符行为会回退到最初的任何预定义行为。

代码片段

代码片段是通过将简短的文本片段扩展为您喜欢的任何内容来改进工作流程的好方法。现在让我们连接这些代码片段。我们将使用 LuaSnip 作为我们的代码片段插件。

在您的 plugins 目录中创建一个新文件,用于配置代码片段插件。

-- lua/plugins/snippets.lua
return {
    {
        'L3MON4D3/LuaSnip',
        conifg = function(opts)
            require('luasnip').setup(opts)
            require('luasnip.loaders.from_snipmate').load({ paths = "./snippets" })
        end,
    },
}

现在我们将代码片段扩展连接到 nvim-cmp 中。首先,我们将 LuaSnip 添加为 nvim-cmp 的依赖项,以确保它在 nvim-cmp 之前加载。然后,我们将其连接到制表符扩展行为中。

{
    "hrsh7th/nvim-cmp",
    version = false,
    event = "InsertEnter",
    dependencies = {
        "hrsh7th/cmp-nvim-lsp",
        "hrsh7th/cmp-path",
        "hrsh7th/cmp-buffer",
        "L3MON4D3/LuaSnip",
    },
    config = function()
        local cmp = require('cmp')
        local luasnip = require('cmp')
        local opts = {
            -- Where to get completion results from
            sources = cmp.config.sources {
                { name = "nvim_lsp" },
                { name = "buffer"},
                { name = "path" },
            },
            mapping = cmp.mapping.preset.insert({
                -- Make 'enter' key select the completion
                ["<CR>"] = cmp.mapping.confirm({ select = true }),
                -- Super-tab behavior
                ["<tab>"] = cmp.mapping(function(original)
                    if cmp.visible() then
                        cmp.select_next_item() -- run completion selection if completing
                    elseif luasnip.expand_or_jumpable() then
                        luasnip.expand_or_jump() -- expand snippets
                    else
                        original()      -- run the original behavior if not completing
                    end
                end, {"i", "s"}),
                ["<S-tab>"] = cmp.mapping(function(original)
                    if cmp.visible() then
                        cmp.select_prev_item()
                    elseif luasnip.expand_or_jumpable() then
                        luasnip.jump(-1)
                    else
                        original()
                    end
                end, {"i", "s"}),
            }),
            snippets = {
                expand = function(args)
                    luasnip.lsp_expand(args)
                end,
            },
        }
        cmp.setup(opts)
    end,
},

现在我们的制表符键在超级制表符方式中被彻底重载。

现在我们需要编写一些代码片段。LuaSnip 支持多种代码片段格式,包括流行的 TextMateVisual Studio Code 代码片段格式及其自身的 基于 Lua 的 API 的子集。

以下是我发现有用的一些代码片段

snippet pub "public access control"
  public $0

snippet priv "private access control"
  private $0

snippet if "if statement"
  if $1 {
    $2
  }$0

snippet ifl "if let"
  if let $1 = ${2:$1} {
    $3
  }$0

snippet ifcl "if case let"
  if case let $1 = ${2:$1} {
    $3
  }$0

snippet func "function declaration"
  func $1($2) $3{
    $0
  }

snippet funca "async function declaration"
  func $1($2) async $3{
    $0
  }

snippet guard
  guard $1 else {
    $2
  }$0

snippet guardl
  guard let $1 else {
    $2
  }$0

snippet main
  @main public struct ${1:App} {
    public static func main() {
      $2
    }
  }$0

另一个值得一提的流行的代码片段插件是 UltiSnips,它允许您在定义代码片段时使用内联 Python,从而允许您编写一些非常强大的代码片段。

结论

一旦所有内容都正确配置,使用 Neovim 进行 Swift 开发是一种可靠的体验。有成千上万的插件供您探索,本文为您在 Neovim 中构建 Swift 开发体验奠定了坚实的基础。

文件

以下是此配置的最终形式的文件。

-- init.lua
local lazypath = vim.fn.stdpath("data") .. "/lazy/lazy.nvim"
if not vim.loop.fs_stat(lazypath) then
    vim.fn.system({
        "git",
        "clone",
        "--filter=blob:none",
        "https://github.com/folke/lazy.nvim.git",
        "--branch=stable",
        lazypath
    })
end
vim.opt.rtp:prepend(lazypath)
require("lazy").setup("plugins", {
  ui = {
    icons = {
      cmd = "",
      config = "",
      event = "",
      ft = "",
      init = "",
      keys = "",
      plugin = "",
      runtime = "",
      require = "",
      source = "",
      start = "",
      task = "",
      lazy = "",
    },
  },
})

vim.opt.wildmenu = true
vim.opt.wildmode = "list:longest,list:full" -- don't insert, show options

-- line numbers
vim.opt.nu = true
vim.opt.rnu = true

-- textwrap at 80 cols
vim.opt.tw = 80

-- set solarized colorscheme.
-- NOTE: Uncomment this if you have installed solarized, otherwise you'll see
--       errors.
-- vim.cmd.background = "dark"
-- vim.cmd.colorscheme("solarized")
-- vim.api.nvim_set_hl(0, "NormalFloat", { bg = "none" })
-- lua/plugins/codecompletion.lua
return {
  {
    "hrsh7th/nvim-cmp",
    version = false,
    event = "InsertEnter",
    dependencies = {
      "hrsh7th/cmp-nvim-lsp",
      "hrsh7th/cmp-path",
      "hrsh7th/cmp-buffer",
    },
    config = function()
      local cmp = require('cmp')
      local luasnip = require('luasnip')
      local opts = {
        sources = cmp.config.sources {
          { name = "nvim_lsp", },
          { name = "path", },
          { name = "buffer", },
        },
        mapping = cmp.mapping.preset.insert({
          ["<CR>"] = cmp.mapping.confirm({ select = true }),
          ["<tab>"] = cmp.mapping(function(original)
            print("tab pressed")
            if cmp.visible() then
              print("cmp expand")
              cmp.select_next_item()
            elseif luasnip.expand_or_jumpable() then
              print("snippet expand")
              luasnip.expand_or_jump()
            else
              print("fallback")
              original()
            end
          end, {"i", "s"}),
          ["<S-tab>"] = cmp.mapping(function(original)
            if cmp.visible() then
              cmp.select_prev_item()
            elseif luasnip.expand_or_jumpable() then
              luasnip.jump(-1)
            else
              original()
            end
          end, {"i", "s"}),

        })
      }
      cmp.setup(opts)
    end,
  },
  { "hrsh7th/cmp-nvim-lsp", lazy = true },
  { "hrsh7th/cmp-path", lazy = true },
  { "hrsh7th/cmp-buffer", lazy = true },
}
-- lua/plugins/lsp.lua
return {
  {
    "neovim/nvim-lspconfig",
    config = function()
      local lspconfig = require('lspconfig')
    lspconfig.sourcekit.setup {
      capabilities = {
          workspace = {
            didChangeWatchedFiles = {
              dynamicRegistration = true,
            },
          },
        },
      }

      vim.api.nvim_create_autocmd('LspAttach', {
        desc = "LSP Actions",
        callback = function(args)
          vim.keymap.set("n", "K", vim.lsp.buf.hover, {noremap = true, silent = true})
          vim.keymap.set("n", "gd", vim.lsp.buf.definition, {noremap = true, silent = true})
        end,
      })
    end,
  },
}
-- lua/plugins/snippets.lua
return {
  {
    'L3MON4D3/LuaSnip',
    lazy = false,
    config = function(opts)
      local luasnip = require('luasnip')
      luasnip.setup(opts)
      require('luasnip.loaders.from_snipmate').load({ paths = "./snippets"})
    end,
  }
}
# snippets/swift.snippets

snippet pub "public access control"
  public $0

snippet priv "private access control"
  private $0

snippet if "if statement"
  if $1 {
    $2
  }$0

snippet ifl "if let"
  if let $1 = ${2:$1} {
    $3
  }$0

snippet ifcl "if case let"
  if case let $1 = ${2:$1} {
    $3
  }$0

snippet func "function declaration"
  func $1($2) $3{
    $0
  }

snippet funca "async function declaration"
  func $1($2) async $3{
    $0
  }

snippet guard
  guard $1 else {
    $2
  }$0

snippet guardl
  guard let $1 else {
    $2
  }$0

snippet main
  @main public struct ${1:App} {
    public static func main() {
      $2
    }
  }$0