Building a Modern Neovim Configuration on z/OS

After my three-part VIM series, several readers asked: “What about Neovim?”

Good question – and one I’ve been exploring myself. Neovim offers some compelling advantages: Lua configuration, built-in LSP support since version 0.11, and a more modern plugin ecosystem. The challenge? Getting it to work on z/OS with its unique constraints.

This article presents my current working configuration. I’m using this setup at Beta Systems Software AG for z/OS development. It’s been battle-tested on real projects. If you’ve read my VIM series, you’ll recognize the philosophy: understand what you’re adding, keep it simple, make it yours.

Why Neovim on z/OS?

Neovim 0.11 introduced native LSP support, no plugins required for basic language server integration. Combined with Lua configuration, this means:

  • Cleaner, more readable configs than VimScript
  • First-class LSP without CoC or similar plugins
  • Modern plugin ecosystem
  • Better async capabilities

The catch: z/OS has Lua 5.1, not LuaJIT. Many popular plugins require LuaJIT and won’t work. We’ll navigate around this limitation.

Prerequisites

You’ll need:

Installation

zopen install neovim -y

Critical: Neovim on z/OS requires a proper terminal setting:

export TERM=xterm-256color

Add this to your ~/.bashrc or ~/.profile. Without it, colors and UI elements won’t work correctly.

Critical: Neviom will not work correctly when the environment variable VIMRUNTIME is set.

export VIMRUNTIME=$HOME/zopen/usr/local/share/vim/vim91/

Remove the line above from your ~/.bashrc or ~/.profile.

Verify the installation:

nvim --version
# Should show: 
NVIM v0.11.5
Build type: Release
Lua 5.1
Run "nvim -V1 -v" for more info

Configuration structure

Neovim can work with a single init.lua file, just like VIM’s .vimrc. However, for better readability and maintainability, we’ll organize our configuration into multiple files:

~/.config/nvim/
├── init.lua              # Entry point
├── lua/
   ├── options.lua       # Editor settings
   ├── keymaps.lua       # Key bindings
   ├── plugins.lua       # Plugin definitions
   └── config/           # Plugin configurations
       ├── lualine.lua
       ├── lsp.lua
       ├── completion.lua
       ├── treesitter.lua
       ├── diff.lua
       ├── pick.lua
       └── neotree.lua
└── lsp/
    └── clangd.lua        # Language server config

Create the directories:

mkdir -p ~/.config/nvim/lua/config
mkdir -p ~/.config/nvim/lsp

Options

Create ~/.config/nvim/lua/options.lua:

local opt = vim.opt

-- Line numbers
opt.number = true
opt.relativenumber = true

-- Tabs & indentation
opt.tabstop = 2
opt.shiftwidth = 2
opt.expandtab = true
opt.smartindent = true

-- Search
opt.ignorecase = true
opt.smartcase = true
opt.hlsearch = true
opt.incsearch = true

-- UI
opt.termguicolors = true
opt.cursorline = true
opt.scrolloff = 8
opt.signcolumn = "yes:3"
opt.colorcolumn = "72,80"
opt.winborder = "rounded"

-- Show whitespace
opt.list = true
opt.listchars = {
  trail = "·",
  tab = "» ",
  nbsp = "␣",
}

-- Behavior
opt.splitright = true
opt.splitbelow = true
opt.mouse = "a"
opt.clipboard = "unnamedplus"
opt.completeopt = "menuone,noinsert"

-- Folding
opt.foldmethod = "expr"
opt.foldexpr = "v:lua.vim.lsp.foldexpr()"
-- opt.foldexpr = "v:lua.vim.treesitter.foldexpr()"
opt.foldcolumn = "0"
opt.foldtext = ""
opt.fillchars = 'eob: ,fold: ,foldopen:,foldsep: ,foldclose:'
opt.foldlevel = 99
-- opt.foldlevelstart = 1
opt.foldnestmax = 4

-- No backup/swap files
opt.swapfile = false
opt.backup = false

-- Persistent undo
opt.undofile = true

-- Disable unused providers
vim.g.loaded_python3_provider = 0
vim.g.loaded_ruby_provider = 0
vim.g.loaded_perl_provider = 0
vim.g.loaded_node_provider = 0

If you’ve read my VIM series, most of these will be familiar. They’re just written in Lua syntax now. The provider settings disable Python/Ruby/Perl/Node integrations we don’t need, speeding up startup and removing health-check warnings.

Keymaps

Create ~/.config/nvim/lua/keymaps.lua:

vim.g.mapleader = " "
vim.g.maplocalleader = " "

local map = vim.keymap.set

-- Clear search highlight
map("n", "<leader>/", ":nohlsearch<CR>", { desc = "Clear search" })

-- Buffer navigation
map("n", "<C-j>", ":bnext<CR>", { desc = "Next buffer" })
map("n", "<C-k>", ":bprev<CR>", { desc = "Previous buffer" })
map("n", "<leader>bd", ":bdelete<CR>", { desc = "Delete buffer" })

-- Keep selection when indenting
map("v", "<", "<gv", { desc = "Indent left" })
map("v", ">", ">gv", { desc = "Indent right" })

-- Move lines
map("v", "J", ":m '>+1<CR>gv=gv", { desc = "Move line down" })
map("v", "K", ":m '<-2<CR>gv=gv", { desc = "Move line up" })

-- Toggle UI for copying
map("n", "<leader>n", function()
  vim.wo.number = not vim.wo.number
  vim.wo.relativenumber = not vim.wo.relativenumber
  if vim.wo.signcolumn == "yes:3" then
    vim.wo.signcolumn = "no"
  else
    vim.wo.signcolumn = "yes:3"
  end
end, { desc = "Toggle line numbers and signs" })

-- Remove trailing whitespace
map("n", "<leader>tw", [[:%s/\s\+$//e<CR>]], { desc = "Remove trailing whitespace" })

The <leader>n toggle is useful when you need to copy text from the terminal. It hides line numbers and the sign column so they don’t get included in your selection.

Plugin manager

Here’s our first z/OS challenge. The popular lazy.nvim plugin manager requires LuaJIT, so it won’t work. Instead, we’ll use paq-nvim: minimal, pure Lua, works with Lua 5.1.

Note: Neovim 0.12 will include a built-in plugin manager. Once that version is ported to z/OS, I’ll write a follow-up article on migrating away from paq-nvim.

Install it:

git clone --depth=1 https://github.com/savq/paq-nvim.git \
  "${XDG_DATA_HOME:-$HOME/.local/share}"/nvim/site/pack/paqs/start/paq-nvim

Create ~/.config/nvim/lua/plugins.lua:

require("paq")({
  "savq/paq-nvim",                -- package manager (replaces lazy.nvim)
  "folke/tokyonight.nvim",        -- color theme
  "nvim-lualine/lualine.nvim",    -- status line
  "echasnovski/mini.completion",  -- completion  (replaces blink.nvim)
  "echasnovski/mini.diff",        -- git signs 
  "echasnovski/mini.pick",        -- finder
  "echasnovski/mini.icons",       -- dependency 
  "nvim-lua/plenary.nvim",        -- dependency
  "nvim-tree/nvim-web-devicons",  -- dependency
  "MunifTanjim/nui.nvim",         -- dependency
  "nvim-neo-tree/neo-tree.nvim",  -- file browser 
  "chentoast/marks.nvim",         -- better marks
})

You’ll notice several mini.* plugins. The mini.nvim collection is pure Lua and works reliably on z/OS. Many popular alternatives (like gitsigns.nvim, telescope.nvim, blink.cmp) failed due to LuaJIT dependencies or other compatibility issues.

Entry point

Create ~/.config/nvim/init.lua:

require("options")
require("keymaps")
require("plugins")

vim.cmd.colorscheme("tokyonight")

require("config.lualine")
require("config.lsp")
require("config.completion")
require("config.treesitter")
require("config.diff")
require("config.pick")
require("config.neotree")
require("config.marks")

vim.lsp.enable("clangd")
-- vim.lsp.enable({"clangd","bashls","hlasmls"}) -- this way you can enbale mutliple lsp servers

Start Neovim and install plugins:

nvim
:PaqSync

Restart Neovim after installation completes.

Plugin configurations

Now let’s configure each plugin.

Statusline

Create ~/.config/nvim/lua/config/lualine.lua:

-- Custom components for whitespace issues
local function trailing_whitespace()
  local space = vim.fn.search([[\s\+$]], "nwc")
  return space ~= 0 and "TW:" .. space or ""
end

local function mixed_indent()
  local spaces = vim.fn.search([[^\s* \t]], "nwc")
  local tabs = vim.fn.search([[^\s*\t ]], "nwc")
  if spaces ~= 0 or tabs ~= 0 then
    return "MI"
  end
  return ""
end

require("lualine").setup({
  options = {
    theme = "tokyonight",
    section_separators = "",
    component_separators = "",
  },
  sections = {
    lualine_a = { "mode" },
    lualine_b = { "branch", "diff" },
    lualine_c = { "filename" },
    lualine_x = {
      { trailing_whitespace, color = { fg = "#ff6666" } },
      { mixed_indent, color = { fg = "#ff6666" } },
      "encoding",
      "filetype",
    },
    lualine_y = { "progress" },
    lualine_z = { "location" },
  },
})

This adds two custom indicators that show in red when there are issues: TW:42 means trailing whitespace on line 42, MI means mixed indentation detected.

Completion

Create ~/.config/nvim/lua/config/completion.lua:

require("mini.completion").setup({
  delay = { completion = 100, info = 100, signature = 50 },
  lsp_completion = {
    source_func = "omnifunc",
    auto_setup = true,
  },
})

This provides automatic completion popups. Combined with our completeopt setting, it won’t auto-insert. You navigate with Tab/Shift-Tab and confirm with Enter.

Treesitter

Create ~/.config/nvim/lua/config/treesitter.lua:

-- Enable treesitter for supported filetypes
vim.api.nvim_create_autocmd("FileType", {
  pattern = { "c", "cpp", "lua", "vim", "markdown" },
  callback = function()
    pcall(vim.treesitter.start)
  end,
})

Neovim 0.11 ships with parsers for several languages. This enables treesitter highlighting for those filetypes. The pcall prevents errors if a parser is missing.

Git diff

Create ~/.config/nvim/lua/config/diff.lua:

require("mini.diff").setup({
  view = {
    style = "sign",
    signs = {
      add = "┃",
      change = "┃",
      delete = "_",
    },
  },
})

Shows Git changes in the sign column. If you experience display issues (especially when using tmux, more on that in a future article), fall back to ASCII characters like +, ~, and -.

Fuzzy finder

Create ~/.config/nvim/lua/config/pick.lua:

require("mini.pick").setup()

local map = vim.keymap.set

map("n", "<leader>f", "<cmd>Pick files<cr>", { desc = "Find files" })
map("n", "<leader>b", "<cmd>Pick buffers<cr>", { desc = "Find buffers" })
map("n", "<leader>g", "<cmd>Pick grep_live<cr>", { desc = "Live grep" })
map("n", "<leader>h", "<cmd>Pick oldfiles<cr>", { desc = "Recent files" })

We use mini.pick instead of telescope.nvim or fzf-lua because it’s pure Lua with no external dependencies. It just works.

File explorer

Create ~/.config/nvim/lua/config/neotree.lua:

require("neo-tree").setup({
  filesystem = {
    filtered_items = {
      visible = true,
      hide_dotfiles = false,
      hide_gitignored = false,
    },
  },
  default_component_configs = {
    icon = {
      folder_closed = "▶",
      folder_open = "▼",
      folder_empty = "▷",
      default = "◇",
    },
    git_status = {
      symbols = {
        added = "✚",
        modified = "●",
        deleted = "✖",
        renamed = "➜",
        untracked = "◌",
        ignored = "◌",
        unstaged = "○",
        staged = "●",
        conflict = "!",
      },
    },
  },
})

vim.keymap.set("n", "<leader>e", "<cmd>Neotree toggle<cr>", { desc = "File explorer" })

Again, if Unicode symbols don’t display correctly in your terminal, replace them with ASCII alternatives like >, v, +, ~, etc.

Better marks

Create ~/.config/nvim/lua/config/marks.lua:

require("marks").setup({})

LSP configuration

This is where Neovim 0.11 shines. Native LSP support means no CoC, no external plugins, just configuration.

Language server definition

Neovim 0.11 uses files in ~/.config/nvim/lsp/ to define language servers. For clangd, get the default configuration from nvim-lspconfig:

curl -o ~/.config/nvim/lsp/clangd.lua \
  https://raw.githubusercontent.com/neovim/nvim-lspconfig/master/lsp/clangd.lua

This provides sensible defaults for clangd, including file types, root markers, and capabilities. We deliberately curl these config files instead of using nvim-lspconfig as a plugin. This keeps the startup fast and allows us to customize configurations for z/OS-specific needs.

LSP keymaps and diagnostics

Create ~/.config/nvim/lua/config/lsp.lua:

-- Diagnostics configuration
vim.diagnostic.config({
  virtual_text = true,
  signs = {
    text = {
      [vim.diagnostic.severity.ERROR] = "✘",
      [vim.diagnostic.severity.WARN] = "▲",
      [vim.diagnostic.severity.INFO] = "ℹ",
      [vim.diagnostic.severity.HINT] = "➤",
    },
  },
  underline = true,
  update_in_insert = false,
  float = {
    border = "rounded",
    source = true,
  },
})

-- Keymaps on LSP attach
vim.api.nvim_create_autocmd("LspAttach", {
  callback = function(args)
    local opts = { buffer = args.buf }
    local map = vim.keymap.set

    -- Navigation
    map("n", "gd", vim.lsp.buf.definition, opts)
    map("n", "gr", vim.lsp.buf.references, opts)
    map("n", "gi", vim.lsp.buf.implementation, opts)
    map("n", "gy", vim.lsp.buf.type_definition, opts)
    map("n", "K", vim.lsp.buf.hover, opts)

    -- Code actions
    map("n", "<leader>cr", vim.lsp.buf.rename, opts)
    map("n", "<leader>ca", vim.lsp.buf.code_action, opts)
    map("n", "<leader>cf", function() vim.lsp.buf.format({ async = true }) end, opts)

    -- Diagnostics
    map("n", "<leader>dn", vim.diagnostic.goto_next, opts)
    map("n", "<leader>dp", vim.diagnostic.goto_prev, opts)
    map("n", "<leader>dd", vim.diagnostic.open_float, opts)
  end,
})

The keymaps only activate when an LSP client attaches to a buffer. No LSP, no keymaps clobbering your normal workflow. If the Unicode symbols for diagnostics don’t display correctly, use ASCII alternatives: E, W, I, H.

Installing clangd

If you haven’t already:

zopen install clangd -y
zopen install clang-format -y

Verify:

clangd --version

Other language servers

The same native LSP setup works with other language servers. I also use:

  • bash-language-server – for shell scripts, could be installed via npm
  • HLASM Language Server – my port from the Eclipse Che4z project, coming to zopen soon

To add another language server, download the corresponding config from nvim-lspconfig:

# Bash language server
curl -o ~/.config/nvim/lsp/bashls.lua \
  https://raw.githubusercontent.com/neovim/nvim-lspconfig/master/lsp/bashls.lua

# HLASM language server (when available)
curl -o ~/.config/nvim/lsp/hlasm.lua \
  https://raw.githubusercontent.com/neovim/nvim-lspconfig/master/lsp/hlasm.lua

Then enable it in your init.lua:

vim.lsp.enable({"bashls", "hlasm", "clangd")

Why curl instead of using nvim-lspconfig as a plugin? Performance. The nvim-lspconfig plugin adds startup overhead. By curling only the configs we need, we keep things lean. You can also customize these files for z/OS-specific settings.

Testing the setup

Open a C file:

nvim test.c

Check LSP status:

:checkhealth vim.lsp

You should see clangd as an active client. Now test the features:

  • K on a symbol – hover documentation
  • gd – go to definition
  • gr – find references
  • Start typing – completion popup appears
  • Space cf – format code (uses clang-format)
  • Space e – file explorer
  • Space f – find files
  • Space g – live grep

Complete keybinding reference

General:

  • Space / – Clear search highlight
  • Space n – Toggle line numbers and signs
  • Space tw – Remove trailing whitespace
  • Ctrl-j/k – Next/previous buffer
  • Space bd – Delete buffer

Fuzzy finder:

  • Space f – Find files
  • Space b – Find buffers
  • Space g – Live grep
  • Space h – Recent files

File explorer:

  • Space e – Toggle neo-tree

LSP (when attached):

  • gd – Go to definition
  • gr – Find references
  • gi – Go to implementation
  • gy – Go to type definition
  • K – Hover documentation
  • Space cr – Rename symbol
  • Space ca – Code action
  • Space cf – Format code
  • Space dn – Next diagnostic
  • Space dp – Previous diagnostic
  • Space dd – Show diagnostic float

z/OS specific notes

What works:

  • Native LSP with clangd
  • mini.nvim plugin collection
  • neo-tree, lualine, tokyonight
  • Treesitter (built-in parsers)

What doesn’t work (LuaJIT required):

  • lazy.nvim (use paq-nvim instead)
  • telescope.nvim (use mini.pick instead)
  • blink.cmp (use mini.completion instead)
  • gitsigns.nvim (use mini.diff instead)
  • GitHub Copilot (native bindings not available)

Key takeaways

  • Neovim 0.11’s native LSP eliminates the need for CoC
  • Lua configuration is cleaner than VimScript
  • z/OS’s Lua 5.1 limits plugin choices – mini.nvim is your friend
  • The setup is modular – easy to add, remove, or modify parts

Whether you prefer VIM or Neovim is a matter of personal preference. Both can be excellent IDEs on z/OS. VIM has broader plugin compatibility and a more mature ecosystem with more features available. However, for LSP functionality, Neovim’s native LSP is noticeably faster than VIM with CoC. The Node.js dependency adds significant overhead on z/OS. Neovim also offers a cleaner Lua configuration compared to VimScript.

Related:

Questions or feedback? Drop a comment below!

Leave a Comment