Skip to content

Neovim Configuration

Wojciech Kulik edited this page Dec 7, 2025 · 10 revisions

Below you will find basic information on how to prepare your Neovim for iOS development.

If you'd like to get more details, you can read my article: The complete guide to iOS & macOS development in Neovim.

 

🛋️  Sample Config for iOS

Here you can find my sample config for iOS development. You can try it out without touching your own config.

ios-dev-starter-nvim

 

🧰  LSP

Apple provides together with Xcode an LSP server called sourcekit-lsp. You can integrate it by using nvim-lspconfig plugin. To properly display code completion you will also need nvim-cmp or blink.cmp. On top of that, you also need Build Server Protocol (BSP), which will let the LSP understand the project structure (xcodeproj / xcworkspace). For that purpose, you need to install xcode-build-server.

👉 nvim-lspconfig configuration
return {
  "neovim/nvim-lspconfig",
  dependencies = {
    { "hrsh7th/cmp-nvim-lsp" }, -- choose which plugin you use
    { "saghen/blink.cmp" }, -- choose which plugin you use
    { "antosha417/nvim-lsp-file-operations", config = true },
  },
  config = function()
    -- choose which plugin you use:
    local capabilities = require("cmp_nvim_lsp").default_capabilities()
    -- local capabilities = require("blink.cmp").get_lsp_capabilities()

    local lspconfig = vim.lsp.config
    local opts = { noremap = true, silent = true }
    local on_attach = function(_, bufnr)
      opts.buffer = bufnr

      opts.desc = "Show line diagnostics"
      vim.keymap.set("n", "<leader>d", vim.diagnostic.open_float, opts)

      opts.desc = "Show documentation for what is under cursor"
      vim.keymap.set("n", "K", vim.lsp.buf.hover, opts)

      opts.desc = "Show LSP definition"
      vim.keymap.set("n", "gd", "<cmd>Telescope lsp_definitions trim_text=true<cr>", opts)
    end

    lspconfig("sourcekit", {
      capabilities = capabilities,
      on_attach = on_attach,
      root_dir = function(_, callback)
        callback(
          require("lspconfig.util").root_pattern("Package.swift")(vim.fn.getcwd())
            or require("lspconfig.util").find_git_ancestor(vim.fn.getcwd())
        )
      end,
      cmd = { vim.trim(vim.fn.system("xcrun -f sourcekit-lsp")) }
    })
    vim.lsp.enable("sourcekit")

    -- nice icons
    local signs = { Error = "", Warn = "", Hint = "󰠠 ", Info = "" }
    for type, icon in pairs(signs) do
      local hl = "DiagnosticSign" .. type
      vim.fn.sign_define(hl, { text = icon, texthl = hl, numhl = "" })
    end
  end,
}
👉 nvim-cmp configuration
return {
  "hrsh7th/nvim-cmp",
  event = "InsertEnter",
  dependencies = {
    "hrsh7th/cmp-buffer", -- source for text in buffer
    "hrsh7th/cmp-path", -- source for file system paths
    "L3MON4D3/LuaSnip", -- snippet engine
    "saadparwaiz1/cmp_luasnip", -- for autocompletion
    "rafamadriz/friendly-snippets", -- useful snippets
    "onsails/lspkind.nvim", -- vs-code like pictograms
  },
  config = function()
    local cmp = require("cmp")
    local luasnip = require("luasnip")
    local lspkind = require("lspkind")

    -- loads vscode style snippets from installed plugins (e.g. friendly-snippets)
    require("luasnip.loaders.from_vscode").lazy_load()

    cmp.setup({
      completion = {
        completeopt = "menu,menuone,preview",
      },
      snippet = { -- configure how nvim-cmp interacts with snippet engine
        expand = function(args)
          luasnip.lsp_expand(args.body)
        end,
      },
      mapping = cmp.mapping.preset.insert({
        ["<C-k>"] = cmp.mapping.select_prev_item(), -- previous suggestion
        ["<C-j>"] = cmp.mapping.select_next_item(), -- next suggestion
        ["<C-Space>"] = cmp.mapping.complete(), -- show completion suggestions
        ["<C-e>"] = cmp.mapping.abort(), -- close completion window
        ["<CR>"] = cmp.mapping.confirm({ select = false, behavior = cmp.ConfirmBehavior.Replace }),
        ["<C-b>"] = cmp.mapping(function(fallback)
          if luasnip.jumpable(-1) then
            luasnip.jump(-1)
          else
            fallback()
          end
        end, { "i", "s" }),
        ["<C-f>"] = cmp.mapping(function(fallback)
          if luasnip.jumpable(1) then
            luasnip.jump(1)
          else
            fallback()
          end
        end, { "i", "s" }),
      }),
      -- sources for autocompletion
      sources = cmp.config.sources({
        { name = "nvim_lsp" },
        { name = "luasnip" }, -- snippets
        { name = "buffer" }, -- text within current buffer
        { name = "path" }, -- file system paths
      }),
      -- configure lspkind for vs-code like pictograms in completion menu
      formatting = {
        format = lspkind.cmp_format({
          maxwidth = 50,
          ellipsis_char = "...",
        }),
      },
    })
  end,
}
👉 blink.cmp configuration
return {
  "saghen/blink.cmp",
  dependencies = {
    "rafamadriz/friendly-snippets",
    "echasnovski/mini.icons",
    "onsails/lspkind-nvim",
  },
  event = "VeryLazy",
  version = "*",
  opts = {
    enabled = function()
      return not vim.tbl_contains({ "oil" }, vim.bo.filetype)
    end,
    keymap = {
      preset = "enter",
      ["<C-h>"] = {
        function(cmp)
          cmp.show_documentation()
        end,
      },
      ["<tab>"] = {},
    },
    signature = { enabled = false },
    appearance = {
      -- use_nvim_cmp_as_default = true,
      nerd_font_variant = "mono",
    },
    sources = {
      default = { "lsp", "path", "snippets", "buffer" },
    },

    cmdline = {
      keymap = {
        ["<Tab>"] = { "show", "select_next" },
        ["<S-Tab>"] = { "select_prev" },
        ["<cr>"] = { "select_and_accept", "fallback" },
        ["<space>"] = { "select_and_accept", "fallback" },
        ["<right>"] = { "select_and_accept", "fallback" },
        ["<down>"] = { "select_next", "fallback" },
        ["<up>"] = { "select_prev", "fallback" },
        ["<esc>"] = {
          "cancel",
          function()
            vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes("<C-c>", true, false, true), "n", true)
          end,
        },
      },

      sources = function()
        local type = vim.fn.getcmdtype()
        -- Search forward and backward
        if type == "/" or type == "?" then
          return {}
        end
        -- Commands
        if type == ":" or type == "@" then
          return { "cmdline", "path" }
        end
        return {}
      end,
      completion = { ghost_text = { enabled = false } },
    },

    completion = {
      trigger = {
        show_on_trigger_character = true,
      },
      documentation = {
        auto_show = true,
        auto_show_delay_ms = 200,
        window = {
          border = "rounded",
          winhighlight = "Normal:Normal,FloatBorder:FloatBorder,CursorLine:BlinkCmpDocCursorLine,Search:None",
        },
      },
      list = {
        selection = {
          auto_insert = false,
        },
      },
      menu = {
        border = "rounded",
        draw = {
          gap = 2,
          components = {
            kind_icon = {
              ellipsis = false,
              highlight = function(ctx)
                local _, hl, _ = require("mini.icons").get("lsp", ctx.kind)
                return hl
              end,
              text = function(ctx)
                local icon = require("lspkind").symbolic(ctx.kind, { mode = "symbol" })
                return icon .. ctx.icon_gap
              end,
            },
          },
        },
        winhighlight = "Normal:Normal,FloatBorder:FloatBorder,CursorLine:BlinkCmpMenuSelection,Search:None",
      },
    },
  },
  opts_extend = { "sources.default" },
}
👉 xcode-build-server configuration

First, you need to install it:

brew install xcode-build-server

Then, you can configure it for your project:

xcode-build-server config -workspace <xcworkspace> -scheme <scheme> 
# or
xcode-build-server config -project <xcodeproj> -scheme <scheme> 

Make sure to call it from your project root directory. It should create buildServer.json. Open it and make sure that all the information there is correct.

👉 Improved LSP diagnostic icons
local signs = { Error = "", Warn = "", Hint = "󰠠 ", Info = "" }

for type, icon in pairs(signs) do
  local hl = "DiagnosticSign" .. type
  vim.fn.sign_define(hl, { text = icon, texthl = hl, numhl = "" })
end

vim.diagnostic.config({
  float = { border = "rounded" },
  virtual_text = true,
  signs = {
    text = {
      [vim.diagnostic.severity.ERROR] = signs.Error,
      [vim.diagnostic.severity.WARN] = signs.Warn,
      [vim.diagnostic.severity.HINT] = signs.Hint,
      [vim.diagnostic.severity.INFO] = signs.Info,
    },
    linehl = {
      [vim.diagnostic.severity.ERROR] = "ErrorMsg",
    },
    numhl = {
      [vim.diagnostic.severity.WARN] = "WarningMsg",
    },
  },
})

Once all steps are finished, you should be able to open your project in Neovim and see working code completion. If something doesn't work, make sure to run a clean build from Xcode first. Also, you can run :LspInfo to see if the LSP is properly attached and the root directory is detected.

 

👨‍🎓  SwiftFormat

SwiftFormat is a very popular tool to keep formatting consistent across the project. You can easily integrate it with Neovim by using conform.nvim plugin.

Here is a sample config:

return {
  "stevearc/conform.nvim",
  event = { "BufReadPre", "BufNewFile" },
  config = function()
    local conform = require("conform")

    conform.setup({
      formatters_by_ft = {
        swift = { "swiftformat" },
      },
      format_on_save = function(bufnr)
        local ignore_filetypes = { "oil" }
        if vim.tbl_contains(ignore_filetypes, vim.bo[bufnr].filetype) then
          return
        end

        return { timeout_ms = 500, lsp_fallback = true }
      end,
      log_level = vim.log.levels.ERROR,
    })
  end,
}

The code will be automatically formatted on save event 🔥.

 

👮‍♂️  SwiftLint

Usually, you also need some linter to detect common issues. You can easily integrate SwiftLint using nvim-lint plugin.

Here is a sample config:

return {
  "mfussenegger/nvim-lint",
  event = { "BufReadPre", "BufNewFile" },
  config = function()
    local lint = require("lint")

    lint.linters_by_ft = {
      swift = { "swiftlint" },
    }

    local lint_augroup = vim.api.nvim_create_augroup("lint", { clear = true })

    vim.api.nvim_create_autocmd({ "BufWritePost", "BufReadPost", "InsertLeave", "TextChanged" }, {
      group = lint_augroup,
      callback = function()
        if not vim.endswith(vim.fn.bufname(), "swiftinterface") then
          require("lint").try_lint()
        end
      end,
    })

    vim.keymap.set("n", "<leader>ml", function()
      require("lint").try_lint()
    end, { desc = "Lint file" })
  end,
}

Clone this wiki locally