πŸš€ How I Moved Away from Xcode β€” Introducing swift.nvim

For years, Xcode has been the go-to IDE for Swift development.
But not every project needs the full-blown Apple IDE β€” sometimes, you just want something lighter, faster, and completely configurable.

I wanted to write Swift code the same way I work in most of my other languages β€” inside Neovim, with speed, keyboard-driven precision, and total control.
That’s how swift.nvim was born: a plugin that turns Neovim into a fully featured Swift development environment β€” without needing Xcode at all.

:gear: What swift.nvim Does

swift.nvim aims to replicate the most essential parts of Xcode’s workflow, directly inside Neovim:

  • :magnifying_glass_tilted_left: Automatic Project Detection
    Detects whether your project is SwiftPM or an Xcode project/workspace.

  • :puzzle_piece: Target & Scheme Management
    Automatically lists all available targets and lets you switch between them easily:

    :SwiftSelectTarget
    
    
  • :high_voltage: LSP & Autocompletion Integration
    Configures sourcekit-lsp, nvim-cmp, and LuaSnip automatically β€” giving you completion, hints, and inline documentation.

  • :brick: Full Swift Package Manager Support
    Run builds and tests directly from Neovim using swift build, swift test, and swift run.

  • :light_bulb: Dynamic Statusline
    Displays your current target, project type, and target count right in the statusline.

  • :test_tube: Xcode Compatibility
    If it finds a .xcodeproj or .xcworkspace, it automatically loads available schemes and targets via xcodebuild -list -json.


:laptop: Installation

If you’re using Lazy.nvim:

{
  "devswiftzone/swift.nvim",
  ft = "swift",
  config = function()
    require("swift").setup({
      -- Your configuration here
    })
  end,
}

The plugin will automatically activate when you open any .swift file.


:compass: Typical Workflow

  1. Open your SwiftPM or Xcode project:

    cd MyProject
    nvim Package.swift
    
    
  2. Select your target:

    :SwiftSelectTarget
    
    
  3. Run it directly:

    :!swift run
    
    
  4. Enjoy code completion, diagnostics, and formatting β€” all powered by Swift’s native tooling.


:brain: Philosophy

swift.nvim isn’t meant to replace Xcode β€”
it’s a minimalist, cross-platform alternative for developers who prefer a fast, keyboard-driven workflow and a customizable toolchain.

If you love Neovim and want to keep the power of Swift without the weight of Xcode, this plugin bridges that gap beautifully.


:crystal_ball: Roadmap

Some upcoming features in progress:

  • Native LLDB debugging

  • REPL / Playground-style Swift buffers

  • Interactive test runner

  • Dependency graph visualization for Package.swift


:handshake: Contribute

The project is open source and actively developed here:
:backhand_index_pointing_right: GitHub: https://github.com/devswiftzone/swift.nvim

Feedback from the Swift community is hugely appreciated β€” especially from developers already using Vim or Neovim for daily work.
Suggestions, pull requests, and ideas are all welcome!


:speech_balloon: Final Thoughts

This project was born from the desire to write Swift anywhere, on my own terms.
If you’ve ever wanted to step away from Xcode β€” without losing the magic of the Swift ecosystem β€” this plugin might be your next favorite tool.

:eagle: Swift + Neovim = Freedom, speed, and focus.

13 Likes

If anyone has any new ideas to add, feature to ask for, leave it below, or if you want a detailed explanation of how it works

2 Likes

Is there any debugger support or test discovery and integration, or would you recommend relying on other plugins for that?

2 Likes

I’ve just added support for debugging with lldb. If you have a clean installation of LazyVim, you can add this code inside the file /lua/plugins/swift.lua. That’s how I have it configured.

You will need to install swiftlint and swiftformat with brew:

brew install swiftlint swiftformat
-- ============================================================================
-- Swift.nvim Configuration for LazyVim
-- ============================================================================
--
-- This config integrates swift.nvim with LazyVim's existing setup.
-- LazyVim already includes: nvim-lspconfig, nvim-cmp, LuaSnip, which-key
--
-- ============================================================================

return {
  {
    "devswiftzone/swift.nvim",
    ft = "swift", -- Load only for Swift files
    opts = {
      features = {
        -- LSP (integrates with LazyVim's lspconfig)
        lsp = {
          enabled = true,
          auto_setup = true,
          on_attach = function(client, bufnr)
            -- LazyVim's default LSP keybindings will work automatically
            -- gd, gr, K, etc. are already configured by LazyVim
          end,
        },

        -- Formatter
        formatter = {
          enabled = true,
          auto_format = false, -- Set to true for format on save
          tool = "swiftformat", -- or "swiftformat"
        },

        -- Linter (SwiftLint)
        linter = {
          enabled = true,
          auto_lint = true, -- Lint on save
        },

        -- Build System
        build_runner = {
          enabled = true,
          show_output = true,
          output_position = "botright",
          output_height = 15,
        },

        -- Target Manager
        target_manager = {
          enabled = true,
          auto_select = true,
        },

        -- Debugger (Direct LLDB - No nvim-dap needed!)
        debugger = {
          enabled = true,
          lldb_path = nil, -- Auto-detect

          signs = {
            breakpoint = "●",
            current_line = "➀",
          },

          colors = {
            breakpoint = "DiagnosticError",
            current_line = "DiagnosticInfo",
          },

          window = {
            position = "bottom", -- "bottom", "right", or "float"
            size = 15,
          },
        },

        -- Snippets (LazyVim already has LuaSnip)
        snippets = {
          enabled = true,
        },

        -- Xcode (macOS only)
        xcode = {
          enabled = vim.fn.has("mac") == 1,
        },
      },

      -- Logging
      log_level = "info", -- "debug" for troubleshooting
    },

    config = function(_, opts)
      require("swift").setup(opts)

      -- ======================================================================
      -- KEYBINDINGS
      -- ======================================================================
      --
      -- Using LazyVim's leader key (default: Space)
      -- Organized to avoid conflicts with LazyVim defaults
      --
      -- ======================================================================

      local debugger = require("swift.features.debugger")

      -- ----------------------------------------------------------------------
      -- DEBUGGER: F keys (Standard across all IDEs)
      -- ----------------------------------------------------------------------

      vim.keymap.set("n", "<F5>", debugger.continue, {
        desc = "Debug: Continue/Run",
        silent = true,
      })

      vim.keymap.set("n", "<F9>", debugger.toggle_breakpoint, {
        desc = "Debug: Toggle Breakpoint",
        silent = true,
      })

      vim.keymap.set("n", "<F10>", debugger.step_over, {
        desc = "Debug: Step Over",
        silent = true,
      })

      vim.keymap.set("n", "<F11>", debugger.step_into, {
        desc = "Debug: Step Into",
        silent = true,
      })

      vim.keymap.set("n", "<F12>", debugger.step_out, {
        desc = "Debug: Step Out",
        silent = true,
      })

      -- ----------------------------------------------------------------------
      -- DEBUGGER: <leader>d prefix (debug commands)
      -- ----------------------------------------------------------------------

      vim.keymap.set("n", "<leader>db", debugger.toggle_breakpoint, {
        desc = "Toggle Breakpoint",
      })

      vim.keymap.set("n", "<leader>dB", debugger.clear_breakpoints, {
        desc = "Clear All Breakpoints",
      })

      vim.keymap.set("n", "<leader>dc", debugger.continue, {
        desc = "Continue",
      })

      vim.keymap.set("n", "<leader>dq", debugger.stop, {
        desc = "Stop/Quit Debug",
      })

      vim.keymap.set("n", "<leader>dr", debugger.run, {
        desc = "Run",
      })

      vim.keymap.set("n", "<leader>dv", debugger.show_variables, {
        desc = "Show Variables",
      })

      vim.keymap.set("n", "<leader>dt", debugger.show_backtrace, {
        desc = "Show Backtrace",
      })

      vim.keymap.set("n", "<leader>du", ":SwiftDebugUI<CR>", {
        desc = "Toggle Debug UI",
      })

      vim.keymap.set("n", "<leader>dO", debugger.step_over, {
        desc = "Step Over",
      })

      vim.keymap.set("n", "<leader>dI", debugger.step_into, {
        desc = "Step Into",
      })

      vim.keymap.set("n", "<leader>do", debugger.step_out, {
        desc = "Step Out",
      })

      -- ----------------------------------------------------------------------
      -- SWIFT: <leader>S prefix (Swift-specific commands)
      -- Note: Using <leader>S (capital) to avoid conflict with LazyVim's
      --       <leader>s (search/telescope)
      -- ----------------------------------------------------------------------

      -- Build
      vim.keymap.set("n", "<leader>Sb", ":SwiftBuild<CR>", {
        desc = "Build",
      })

      vim.keymap.set("n", "<leader>Sr", ":SwiftRun<CR>", {
        desc = "Run",
      })

      vim.keymap.set("n", "<leader>St", ":SwiftTest<CR>", {
        desc = "Test",
      })

      vim.keymap.set("n", "<leader>Sc", ":SwiftClean<CR>", {
        desc = "Clean",
      })

      -- Format (also available via LazyVim's <leader>cf)
      vim.keymap.set("n", "<leader>Sf", ":SwiftFormat<CR>", {
        desc = "Format File",
      })

      vim.keymap.set("v", "<leader>Sf", ":SwiftFormatSelection<CR>", {
        desc = "Format Selection",
      })

      -- Lint
      vim.keymap.set("n", "<leader>Sl", ":SwiftLint<CR>", {
        desc = "Lint",
      })

      vim.keymap.set("n", "<leader>SL", ":SwiftLintFix<CR>", {
        desc = "Lint & Fix",
      })

      -- Targets
      vim.keymap.set("n", "<leader>ST", ":SwiftTarget<CR>", {
        desc = "Select Target",
      })

      vim.keymap.set("n", "<leader>Ss", ":SwiftSnippets<CR>", {
        desc = "Snippets",
      })

      -- Info
      vim.keymap.set("n", "<leader>Si", ":SwiftInfo<CR>", {
        desc = "Info",
      })

      vim.keymap.set("n", "<leader>Sv", ":SwiftVersionInfo<CR>", {
        desc = "Version",
      })

      vim.keymap.set("n", "<leader>Sh", ":checkhealth swift<CR>", {
        desc = "Health Check",
      })

      -- ----------------------------------------------------------------------
      -- XCODE: <leader>x prefix (macOS only)
      -- ----------------------------------------------------------------------

      if vim.fn.has("mac") == 1 then
        vim.keymap.set("n", "<leader>xb", ":SwiftXcodeBuild<CR>", {
          desc = "Xcode Build",
        })

        vim.keymap.set("n", "<leader>xs", ":SwiftXcodeSchemes<CR>", {
          desc = "Xcode Select Scheme",
        })

        vim.keymap.set("n", "<leader>xo", ":SwiftXcodeOpen<CR>", {
          desc = "Xcode Open",
        })
      end

      -- ======================================================================
      -- WHICH-KEY INTEGRATION (LazyVim has which-key by default)
      -- ======================================================================

      local wk_ok, wk = pcall(require, "which-key")
      if wk_ok then
        wk.add({
          { "<leader>d", group = "debug" },
          { "<leader>S", group = "swift" },
          { "<leader>x", group = "xcode" },
        })
      end

      -- ======================================================================
      -- AUTOCOMMANDS (Optional - Uncomment to enable)
      -- ======================================================================

      local augroup = vim.api.nvim_create_augroup("SwiftNvim", { clear = true })

      -- Auto-format on save (uncomment to enable)
      -- vim.api.nvim_create_autocmd("BufWritePre", {
      --   group = augroup,
      --   pattern = "*.swift",
      --   callback = function()
      --     vim.cmd("SwiftFormat")
      --   end,
      --   desc = "Auto-format Swift files on save",
      -- })

      -- Show notification when Swift file is opened
      vim.api.nvim_create_autocmd("FileType", {
        group = augroup,
        pattern = "swift",
        once = true,
        callback = function()
          vim.notify("Swift.nvim loaded | Press <Space>S for Swift commands", vim.log.levels.INFO, {
            title = "swift.nvim",
          })
        end,
      })
    end,
  },

  -- ============================================================================
  -- OPTIONAL: Statusline integration with lualine (LazyVim uses lualine)
  -- ============================================================================

  {
    "nvim-lualine/lualine.nvim",
    optional = true,
    opts = function(_, opts)
      -- Add Swift target to statusline
      table.insert(opts.sections.lualine_x, {
        function()
          local ok, target_manager = pcall(require, "swift.features.target_manager")
          if ok then
            local target = target_manager.get_current_target()
            if target then
              return "🎯 " .. target
            end
          end
          return ""
        end,
        cond = function()
          return vim.bo.filetype == "swift"
        end,
      })
    end,
  },

  -- ============================================================================
  -- OPTIONAL: Add Swift to Treesitter (if not already included)
  -- ============================================================================

  {
    "nvim-treesitter/nvim-treesitter",
    optional = true,
    opts = function(_, opts)
      if type(opts.ensure_installed) == "table" then
        vim.list_extend(opts.ensure_installed, { "swift" })
      end
    end,
  },
}

Swift.nvim Quick Reference (LazyVim)

The plugin now uses the correct which-key API and avoids conflicts with LazyVim.

:keyboard: Updated Keybindings

:bug: Debugger (F Keys - unchanged)


F5 - Continue / Run

F9 - Toggle Breakpoint (● / empty)

F10 - Step Over

F11 - Step Into

F12 - Step Out

:bug: Debugger (d - unchanged)


<Space>db - Toggle Breakpoint

<Space>dB - Clear ALL Breakpoints

<Space>dc - Continue

<Space>dq - Stop/Quit Debug

<Space>dr - Run

<Space>dv - Show Variables

<Space>dt - Show Backtrace

<Space>du - Toggle Debug UI

<Space>dO - Step Over

<Space>dI - Step Into

<Space>do - Step Out

:hammer: Swift Commands (S - CHANGED!)

Important: Now using <Space>S (capital S) to avoid conflict with LazyVim's <Space>s (search/telescope)


<Space>Sb - Build

<Space>Sr - Run

<Space>St - Test

<Space>Sc - Clean

<Space>Sf - Format (also in visual mode)

<Space>Sl - Lint

<Space>SL - Lint & Fix

<Space>ST - Select Target

<Space>Ss - Snippets

<Space>Si - Info

<Space>Sv - Version

<Space>Sh - Health Check

:red_apple: Xcode (macOS) (x - unchanged)


<Space>xb - Build with Xcode

<Space>xs - Select Scheme

<Space>xo - Open in Xcode.app

:magnifying_glass_tilted_left: Which-key Integration

Press <Space> and wait to see available commands:


<Space>d β†’ Shows debug menu

<Space>S β†’ Shows swift menu (capital S!)

<Space>x β†’ Shows xcode menu

:white_check_mark: Verify Installation


:checkhealth which-key

:checkhealth swift

Both should now show βœ“ with no warnings.

:light_bulb: Quick Tips

  1. Capital S for Swift: <Space>S (not <Space>s)

  2. Search still works: <Space>s is reserved for LazyVim's search/telescope

  3. Which-key menu: Press <Space> and wait to see all commands

  4. Debug keys: F5, F9, F10, F11, F12 work as expected

:rocket: Quick Test


" Open a Swift file

nvim main.swift

" You should see: "Swift.nvim loaded | Press <Space>S for Swift commands"

" Try the which-key menu

<Space> " Wait 1 second - menu appears

<Space>S " Shows Swift commands

<Space>d " Shows debug commands

:counterclockwise_arrows_button: Example Workflow


" 1. Build

<Space>Sb

" 2. Set breakpoint

<F9>

" 3. Debug

:SwiftBuildAndDebug

" 4. Navigate

<F10> " Step over

<F11> " Step into

" 5. Inspect

<Space>dv " Variables

" 6. Stop

<Space>dq


Command to invoke the tests has always been, "<leader>St", that executes your test targets with swift test.

If you mean to run specific tests and not run them all as in xcode, I could work on that, as a new feature, you can open an issue with any feature you need and I will try to implement them.