blog.craton.devhometravelsdevabout

Setting up Neovim for iOS Development


It started with my neurologist handing me a piece of paper. He called it a “migraine diary”.

It was obviously a photocopy of a photocopy of, most likely, a photocopy.

“Just mark when you have some symptom and we’ll see if it correlates with anything,” my doctor said.

There's gotta be a better way
”There’s gotta be a better way!”

So I set off to build a better tool for myself: Dots Journal.

Would a Google Sheet have worked? Sure. But I wanted to log something quickly on my phone. And I wanted it to be super simple, clear, and have minimal design. Nothing like this existed.

So I set out to get building. My background is in web dev and systems engineering. I’d done mobile dev on iOS and Android in the 2010s, but I’d moved on to having a Neovim and tmux workflow. I wanted to stay with what I was familiar with. Naturally, I reached for React Native.

React Native is great at many things. But spreadsheets apparently isn’t one of them. So I pivoted to Swift and SwiftUI. I was going native, baby.

But the years of muscle memory I’d built up orchestrating my tmux panes, wrangling text in Neovim, having keybinds for managing git… It just wasn’t translating well to Xcode. The built-in vim bindings fell woefully short. What do you mean I have to hit Cmd+S to save?? I want my :w to work, dangit!

Thankfully, a lot of work had been done to integrate Neovim with xcodebuild. Maybe there was a way that I could have my cake and eat it, too. As I’d soon find out, you can indeed get about 90% of the way there, shipping iOS apps from Neovim.

Neovim inside tmux for building native iOS apps
Building Dots Journal in Neovim on macOS

The Setup: What Makes This Work

I assume you’ve already got Neovim setup the way you like it. A plugin manager like lazy.nvim is also required. While some of this works on Linux and Windows, to actually build and run iOS or macOS apps you will need to be running macOS.

Now we can start by getting Neovim to understand Swift.

sourcekit-lsp

Adding language support to Neovim starts with the Language Server Protocol (LSP). Xcode ships with one out of the box: sourcekit-lsp. No need to install it, it should just work.

We just need to define a sourcekit_lsp.lua file in our ~/.config/nvim/lua/lsp/ folder. Let’s walk through setting that up.

We need to find the sourcekit-lsp binary itself. You could just go with expecting in PATH. I prefer my configs to be more portable. So I search the various spots it is likely to be installed: via Xcode, Homebrew, etc.

-- Look for sourcekit-lsp in common locations
local function find_sourcekit_lsp()
  local potential_paths = {
    -- Xcode toolchain
    '/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/sourcekit-lsp',
    -- Standard locations
    '/usr/local/bin/sourcekit-lsp',
    '/usr/bin/sourcekit-lsp',
    -- Homebrew
    '/opt/homebrew/bin/sourcekit-lsp',
  }

  for _, path in ipairs(potential_paths) do
    if vim.fn.executable(path) == 1 then
      return path
    end
  end

  -- Check if it's in PATH
  if vim.fn.executable('sourcekit-lsp') == 1 then
    return 'sourcekit-lsp'
  end

  return nil
end

Then we just lookup the command. If not found, bail from this Lua config. Neovim won’t have Swift support for this session.

local cmd = find_sourcekit_lsp()
if not cmd then
  return
end

Next, we return the core LSP configuration which Neovim expects from this module.

  • cmd - where our sourcekit-lsp binary is located.
  • filetypes - which files this LSP is applicable.
  • root_dir - tells the LSP the root of our project. For this, we look for common files placed in the root of a project. This helps, in particular, if you work on Swift packages within a broader app.
    • Package.swift takes priority, so we can work on Swift packages within our main app’s bundle (e.g. DotsCore versus Dots the app).
    • buildServer.json next so xcodebuild integration works cleanly with the LSP
    • *.xcodeproj and related are your standard Xcode project files
  • on_attach disables diagnostics for generated interface files, which is super useful if you want to jump into system framework headers from your code and not get overwhelmed by tons of diagnostic noise.
  • capabilities - declares client capabilities to sourcekit-lsp. Here we enable dynamicRegistration for file watching, which is arguably more efficient.
return {
  cmd = { cmd },
  filetypes = {
    'swift',
    'objc',
    'objcpp',
  },
  root_dir = function(bufnr, on_dir)
    local fname = vim.api.nvim_buf_get_name(bufnr)

    -- 1. Package.swift (SwiftPM projects - highest priority)
    local package_swift = vim.fs.dirname(vim.fs.find({ 'Package.swift' }, { upward = true, path = fname })[1])
    if package_swift ~= nil then
      on_dir(package_swift)
      return
    end

    -- 2. buildServer.json (Xcode Build Server Protocol)
    local build_server = vim.fs.dirname(vim.fs.find({ 'buildServer.json' }, { upward = true, path = fname })[1])
    if build_server ~= nil then
      on_dir(build_server)
      return
    end

    -- 3. Xcode projects
    local xcode_project = vim.fs.dirname(vim.fs.find(function(name)
      return name:match('%.xcodeproj$') or name:match('%.xcworkspace$')
    end, { upward = true, path = fname, type = 'directory', limit = 1 })[1])
    if xcode_project ~= nil then
      on_dir(xcode_project)
      return
    end

    -- 4. Git repository (fallback)
    local git_root = vim.fs.dirname(vim.fs.find({ '.git' }, { upward = true, path = fname })[1])
    if git_root ~= nil then
      on_dir(git_root)
      return
    end

    -- 5. Generated interface files (sourcekit-lsp internal files)
    if fname:match('/sourcekit%-lsp/') or fname:match('%.swiftinterface$') then
      local interface_root = vim.fs.dirname(fname)
      on_dir(interface_root)
      return
    end

    -- 6. Fallback to file directory
    local fallback_root = vim.fs.dirname(fname)
    if fallback_root and fallback_root ~= '' then
      on_dir(fallback_root)
    end
  end,
  on_attach = function(client, bufnr)
    local fname = vim.api.nvim_buf_get_name(bufnr)

    -- Disable diagnostics for generated interface files
    if fname:match('/sourcekit%-lsp/') or fname:match('%.swiftinterface$') then
      vim.diagnostic.enable(false, { bufnr = bufnr })
    end
  end,
  capabilities = {
    workspace = {
      didChangeWatchedFiles = {
        dynamicRegistration = true,
      },
    },
  },
}

Jump-to-definition, autocomplete, diagnostics, etc should all work at this point. But we’re not integrating much with the Xcode toolchain yet.

The Bridge: buildServer.json

sourcekit-lsp is great for working with Swift, but it has no knowledge of Xcode or how to work with Xcode projects.

Enter xcode-build-server. This sets up a buildServer.json file which is crucial for getting sourcekit-lsp and Neovim to work together. Xcode provides a build server (primarily meant for CI servers) which xcode-build-server connects to via the CLI.

We just need to install some tools via Homebrew and pipx:

# xcode-build-server: the star of the show
# xcbeautify: beautifies Xcode CLI output
# pipx: Python package installer (for pymobiledevice3)
# rg, jq, coreutils: various utilities these projects depend on
brew install xcode-build-server xcbeautify pipx rg jq coreutils

# pymobiledevice3 allows controlling devices and simulators via CLI
pipx install pymobiledevice3

As our project changes, we’ll use xcode-build-server to refresh our buildServer.json file.

We’ll manage this with a Neovim plugin later, but to get started, go to the root of your project and run:

# if you have a .xcodeproj:
xcode-build-server config -project *.xcodeproj -scheme <name>
# if you have a .xcworkspace:
xcode-build-server config -workspace *.xcworkspace -scheme <name> 

# example from Dots:
xcode-build-server config -project Dots.xcodeproj -scheme Dots_iOS

You’ll need to use Xcode to build up the project or workspace files initially. In a follow-up post, I’ll discuss how this can be brought to the terminal using XcodeGen.

Building & Running: xcodebuild.nvim

xcodebuild.nvim is the lynchpin of our Neovim setup which makes all of this possible. It wraps xcodebuild and provides a clean integration with Telescope, inline build diagnostics, keybinds for building & running, menus for managing devices, etc.

We’ll first need to install it as a Neovim plugin, depending on your plugin manager of choice. I prefer lazy.nvim.

{
  'wojciech-kulik/xcodebuild.nvim',
  ft = { 'swift' },
  dependencies = {
    'MunifTanjim/nui.nvim',            -- required: library used by xcodebuild.nvim
    'nvim-telescope/telescope.nvim',   -- optional: very useful fuzzy finder + list manager
    'nvim-treesitter/nvim-treesitter', -- optional: treesitter integration, for syntax highlighting and navigation
  },
}

That’s all you really have to do. But you’ll likely want to tweak its base config to do something more custom and specific to your workflow.

I’ve commented some specific changes I made in this next code section that you might find useful.

  config = function()
    require('xcodebuild').setup({
      logs = {
        -- Treat the build log as a special file type.
        -- We'll use this later.
        filetype = 'xcodebuildlog',
      },
      integrations = {
        -- Integrate with pymobiledevice3 (installed earlier)
        -- for device management from within Neovim
        pymobiledevice = {
          enabled = true,
        },
      },
    })

    -- Insert additional configuration as desired
  end

One really annoying quirk I ran into quite often was running a build, only to have its output open a split which hijacked my cursor. I’d think I’m editing some code, only to be trying to edit the build log.

We can fix that by using the special log filetype we set earlier and act on it.

    -- Take our special file type from earlier and
    -- prevent the logs split from hijacking file opens
    -- when we run a build
    vim.api.nvim_create_autocmd('FileType', {
      pattern = 'xcodebuildlog',
      callback = function()
        local win = vim.api.nvim_get_current_win()
        vim.wo[win].winfixbuf = true
      end,
    })

By default, the build results are just isolated in that split. Any errors or warnings don’t go anywhere. We can pipe them into the editor as diagnostics just like Xcode’s red squiggles.

    -- By default, xcodebuild results are just put in a split
    -- but this block will place any warnings or errors directly
    -- in the editor as diagnostics (like Xcode's red squiggles)
    local ns = vim.api.nvim_create_namespace('xcodebuild_diagnostics')
    vim.api.nvim_create_autocmd('User', {
      pattern = 'XcodebuildBuildFinished',
      callback = function()
        vim.diagnostic.reset(ns)
        local qflist = vim.fn.getqflist()
        local diags_by_buf = {}
        for _, item in ipairs(qflist) do
          local bufnr = item.bufnr
          if bufnr and bufnr > 0 then
            if not diags_by_buf[bufnr] then
              diags_by_buf[bufnr] = {}
            end
            table.insert(diags_by_buf[bufnr], {
              lnum = math.max(0, (item.lnum or 1) - 1),
              col = math.max(0, (item.col or 1) - 1),
              message = item.text or '',
              severity = item.type == 'W' and vim.diagnostic.severity.WARN or vim.diagnostic.severity.ERROR,
              source = 'xcodebuild',
            })
          end
        end
        for bufnr, diags in pairs(diags_by_buf) do
          vim.diagnostic.set(ns, bufnr, diags)
        end
      end,
    })

And then we get to the keymaps. Everyone will have different opinions here. But here’s what I got used to and what I have thoroughly enjoyed using.

The core philosophy of my keymap strategy is to make it easy to think about: <leader>x is going to be anything Xcode related. folke/which-key.nvim will serve a helpful menu from there to teach me.

    -- Consider putting map in a shared utility to use throughout your Neovim config
    function map(mode, key, action, description, opts)
      local options = vim.tbl_extend('force', opts, { desc = description })
      vim.keymap.set(mode, key, action, options)
    end

    -- Build & Run
    map('n', '<leader>xb', '<cmd>XcodebuildBuild<cr>', 'Build Project', { noremap = true, silent = true })
    map('n', '<leader>xr', '<cmd>XcodebuildBuildRun<cr>', 'Build & Run Project', { noremap = true, silent = true })

    -- Tests
    map('n', '<leader>xt', '<cmd>XcodebuildTest<cr>', 'Run Tests', { noremap = true, silent = true })
    map('n', '<leader>xT', '<cmd>XcodebuildTestClass<cr>', 'Run This Test Class', { noremap = true, silent = true })

    -- Other utilities
    map('n', '<leader>xd', '<cmd>XcodebuildSelectDevice<cr>', 'Select Device', { noremap = true, silent = true })
    map('n', '<leader>xl', '<cmd>XcodebuildToggleLogs<cr>', 'Toggle Xcodebuild Logs', { noremap = true, silent = true })
    map('n', '<leader>xq', '<cmd>Telescope quickfix<cr>', 'Show QuickFix List', { noremap = true, silent = true })
    map('n', '<leader>X', '<cmd>XcodebuildPicker<cr>', 'Show All Xcodebuild Actions', { noremap = true, silent = true })
KeybindingAction
<leader>xbBuild project
<leader>xrBuild & run project
<leader>xtRun tests
<leader>xTRun current test class
<leader>xdSelect device as the run target
<leader>xlToggle build logs
<leader>xqShow quickfix list
<leader>XShow all xcodebuild actions

Editor Niceties

By now you’ve got a fully functional Neovim setup. But there are a couple other enhancements we can add to the mix to make our workflow even better.

SwiftFormat

I normally shy away from automated formatters, but in the age of LLM coding, they become more important for consistent code style. They can also be especially helpful when first learning a language, to learn the idiomatic ways of formatting code.

You can use SwiftFormat with conform.nvim.

  {
    'stevearc/conform.nvim',
    keys = {
      {
        '<leader>lf',
        function()
          require('conform').format({ timeout_ms = 2500 })
        end,
        desc = 'Format current buffer',
      },
    },
    config = function()
      local conform = require('conform')
      conform.setup({
        formatters_by_ft = {
          swift = {
            'swiftformat',
          },
        },
      })
    end,
  },

I would highly recommend pinning the version for SwiftFormat using a tool like mise-en-place. The project has a tendency to change the default formatter rules between versions, so if you have an unexpected upgrade during a brew upgrade, suddenly all of your code gets reformatted.

SwiftLint

Linters can help ensure decent code quality as well by enforcing line length limits, function complexity, parameter counts, etc. Again, helpful guardrails for LLM coding, and good code hygiene when coding by hand.

SwiftLint integrates well with nvim-lint as a general purpose linter.

  {
    'mfussenegger/nvim-lint',
    event = {
      'BufEnter',
      'BufWritePost',
    },
    keys = {
      {
        '<leader>ll',
        function()
          local lint = require('lint')
          lint.try_lint()
        end,
        desc = 'Lint current buffer',
      },
    },
    config = function()
      local lint = require('lint')

      -- For available linters: https://github.com/mfussenegger/nvim-lint/tree/master?tab=readme-ov-file#available-linters
      lint.linters_by_ft = {
        swift = { 'swiftlint' },
      }
    end,
  },

Console Logging

Getting logs out of the Simulator is a bit trickier when not using Xcode. I settled on this script which I can run in a tmux pane to monitor logs:

#!/bin/zsh
BUNDLE_PREDICATE="app.dotsjournal.dots"
/usr/bin/log stream --style compact --level debug --predicate "subsystem BEGINSWITH \"${BUNDLE_PREDICATE}\""

What Can’t it Do?

Ditching Xcode for Neovim isn’t a 100% complete experience. There are still gaps, and the experience can get a little frustrating at times. Whether it would be more/less frustrating than Xcode, though… that’s debatable.

Archival and deployment are still very much an Xcode thing. There might be a way with Fastlane but I haven’t looked into it.

Instruments / profiling is still exclusively via Apple’s proprietary tooling. I would love tooling as convenient as go tool pprof someday, but Instruments is pretty decent at visualizing performance issues.

Autocomplete can lag pretty hard sometimes. sourcekit-lsp can take quite a while to catch up when making edits across many files. I can’t comment on if this is the same in Xcode or not.

Type checking is super opaque at times but from what I hear this is common with Swift more generally. There have been numerous times a SwiftUI view’s body would just fail to compile with a generic “type checking timed out” error which is fairly maddening. Though it’s usually a hint I’m doing too much in the body and I need to split up the view.

Device management like provisioning, getting logs, etc need to be done through Xcode, reliably. The CLI tools I’ve tried can work, but they all still require some amount of Xcode involvement at least during setup.

StoreKit simulation can work outside of Xcode, but you have to set it up with Xcode first. Managing transactions, resetting plans, changing terms all have to be done in Xcode.

Previews and debugging might be possible with xcodebuild.nvim now (check the wiki) but I haven’t bothered. I’ve been meaning to get more invested into nvim-dap but it’s hard to break the debug logging habit.


For writing code and running builds, this Neovim setup has worked extremely well for me. I built Dots Journal exclusively this way. I find myself not missing Xcode, honestly. The few times I have to open it, it’s usually for something that is arguably nicer to do with a dedicated UI anyway.

I’m hopeful this guide helps you find terminal bliss while developing Swift apps as well.

Sources

Tools

Neovim Plugins