Skip to content

sergluka/fix.nvim

Repository files navigation

fix.nvim

fix.nvim is a Neovim plugin for reading FIX protocol logs. It parses FIX messages with tree-sitter, resolves tags and enum values from vendored FIX dictionaries, and decorates buffers with virtual text.

Features

  • Syntax highlighting through tree-sitter-fix
  • Inline tag and enum annotations with configurable formatters
  • Message summary titles above, below, or at the front of each FIX message
  • SOH (\x01) concealment for readable log files
  • Multiple FIX dictionary versions, including FIXT.1.1 via FIX.5.0SP2
  • Viewport-first rendering with background cache warm-up for large files
  • Persistent parse/decode cache across Neovim sessions
  • Commands for yanking fields/messages, opening online tag docs, and browsing fields with snacks.nvim

Screenshots

FIX tag and value annotations FIX message titles and annotations FIX fields picker

Requirements

  • Neovim 0.11+
  • nvim-treesitter
  • tree-sitter-fix, installed with :TSUpdate fix
  • xml2lua
  • mega.cmdparse
  • mega.logging
  • Optional: snacks.nvim for :FIX picker
  • Development only: Podman >= 5.x for integration tests

Tip

On very large files, Neovim's tree-sitter highlighting can freeze the UI when jumping into unparsed regions. That is independent of fix.nvim. Disable highlighting for those buffers with :lua vim.treesitter.stop(0), or let your distribution's big-file protection handle it. fix.nvim annotations continue to work because the plugin parses asynchronously and renders the viewport first.

Installation

{
  "sergluka/fix.nvim",
  dependencies = {
    "nvim-treesitter/nvim-treesitter",
    "manoelcampos/xml2lua",
    "ColinKennedy/mega.cmdparse",
    "ColinKennedy/mega.logging",
    "folke/snacks.nvim", -- optional; omit if you do not use :FIX picker
  },
  build = { "rockspec", ":TSUpdate fix" },
  opts = {},
}

Usage

Command Lua API Description
:FIX --help Show command help
`:FIX annotations [all tag value
:FIX picker require("fix.snacks").open() Open the fields picker
:FIX browse require("fix").browse_tag_online() Open the Onixs documentation page for the tag under the cursor
:FIX dictionary <PATH> require("fix").use_dictionary(path) Use a custom FIX dictionary XML file or repository directory
:FIX yank [--reg=<REGISTER>] require("fix").yank(reg) Smart yank: current/selected fields for characterwise targets, selected messages for linewise targets
:FIX cache clear require("fix").cache_clear() Clear in-memory and on-disk cache entries for the current file, then re-render

FIX yank accepts visual ranges and Vim operator targets. Characterwise targets yank selected annotated fields; linewise targets yank selected messages.

Custom dictionaries can be configured in setup() or registered at runtime with FIX dictionary. Selection is based on tag 8 (BeginString): a custom dictionary keyed by that version wins, otherwise the bundled dictionary is used.

FIX dictionary accepts a QuickFIX-style XML file or a FIX Repository directory containing Fields.xml and Enums.xml. The dictionary version is detected from the XML and replaces the active dictionary for that version until another custom dictionary is registered for the same version, setup() is run again, or Neovim exits. Examples:

:FIX dictionary xml/custom/binance/spot-fix-oe.xml
:FIX dictionary xml/custom/coinbase/order-entry/FIX42-prod-sand.xml

Configuration

{
  -- Bundled dictionary version used when BeginString (tag 8) is missing or unknown.
  -- Examples: "FIX.4.0", "FIX.4.4", "FIX.5.0SP2", "FIXT.1.1".
  -- "FIXT.1.1" resolves through "FIX.5.0SP2".
  fallback_version = "FIX.4.4",

  -- Custom dictionaries keyed by BeginString/FIX version.
  -- Each version can have one active custom dictionary.
  -- `tags` can be used with or without `path` to add Lua decoders.
  dictionaries = {
    ["FIX.4.4"] = {
      path = "xml/custom/binance/spot-fix-oe.xml",
      mode = "quickfix", -- "auto" | "quickfix" | "repository"
      ---@type table<integer, FixTagDecoder>
      tags = {
        [25035] = function(field, _ctx)
          return {
            tag_text = "MessageHandling",
            value_text = ({ ["1"] = "UNORDERED", ["2"] = "SEQUENTIAL" })[field.value],
          }
        end,
      },
    },
    ["FIX.4.2"] = "xml/custom/coinbase/order-entry/FIX42-prod-sand.xml",
  },

  -- Filetype detection rules passed to vim.filetype.add().
  ft = {
    extensions = { "fix", "fixlog" },
    pattern = { ".*%.fix.txt" },
  },

  annotate = {
    tag = {
      enabled = true,
      formatter = require("fix.formatters.tag").default,
    },
    value = {
      enabled = true,
      formatter = require("fix.formatters.value").default,
    },
    message = {
      enabled = true,
      position = "above", -- "above" | "below" | "front"
      formatter = require("fix.formatters.message").default,
    },
  },

  cache = {
    persist = {
      enabled = true,
      max_files = 20, -- set false to disable the file-count limit
      max_bytes = 100 * 1024 * 1024, -- set false to disable the total-size limit
      dir = nil, -- defaults to stdpath("cache") .. "/fix.nvim"
    },
  },

  render = {
    debounce_ms = 80,
    lines_per_batch = 500,
    viewport_margin = 50,
  },
}

dictionaries may also be a list of paths or { path, mode } entries when each entry infers a different FIX version. If two entries infer the same version, use explicit version keys as shown above.

Custom tag decoders receive the parsed field after XML dictionary lookup and ctx = { version, fields, dictionary }. Return tag_text and/or value_text to replace the XML-derived annotation text; return nil for either key to keep the existing value. A pathless entry such as dictionaries["FIX.4.4"] = { tags = ... } overlays the bundled dictionary for that version. The FixTagDecoder alias is available for LuaLS annotations.

Custom formatters should be pure functions of the provided message or field. Formatter output is cached by line hash and reused across identical lines and buffers, so formatters that read message.lineno, buffer state, or other external state can produce stale annotations.

The persistent cache lives at stdpath("cache")/fix.nvim unless cache.persist.dir is set. Set cache.persist.enabled = false to keep all cache data in memory only. Cache rotation deletes the oldest .mpack files until both enabled limits, max_files and max_bytes, are satisfied. When persistence is enabled, at least one rotation limit must remain enabled.

Rendering is viewport-first. The visible region, plus viewport_margin lines above and below it, is annotated immediately after the edit debounce. The rest of the buffer warms the parse/decode cache in lines_per_batch chunks. Off-screen extmarks are not kept around; annotations are re-applied as you scroll, which keeps large buffers responsive.

Keybindings

No keybindings are set by default. Proposed mappings for ftplugin/fix.lua or your Neovim config:

local fix = require("fix")

local function map(lhs, rhs, desc)
  vim.keymap.set("n", lhs, rhs, { buffer = true, desc = "fix: " .. desc })
end

local function smart_yank()
  return fix.operator_yank_register("+")
end

map("<localleader>t", function()
  fix.annotate_toggle("message")
end, "toggle message annotation")

map("<localleader>T", function()
  fix.annotate_toggle("all")
end, "toggle all annotations")

map("<localleader>x", function()
  fix.browse_tag_online()
end, "open online tag docs")

vim.keymap.set("n", "<localleader>y", smart_yank, {
  expr = true,
  buffer = true,
  desc = "fix: yank target",
})
vim.keymap.set("x", "<localleader>y", function()
  fix.yank("+")
end, {
  buffer = true,
  desc = "fix: yank selection",
})
vim.keymap.set(
  "n",
  "<localleader>yy",
  function() return fix.operator_yank_register("+") .. "_" end,
  { expr = true, buffer = true, desc = "fix: yank line" }
)

map("<localleader><localleader>", function()
  require("fix.snacks").open()
end, "open field picker")

Use fix.operator_yank_register() without an argument to write to Vim's default unnamed register. Pass "+" or another register name to make that mapping use a specific register by default.

The following navigation mappings require nvim-treesitter-textobjects:

local ok, ts_move = pcall(require, "nvim-treesitter-textobjects.move")
if not ok then
  ts_move = require("nvim-treesitter.textobjects.move")
end

local function ts_map(lhs, method, query, desc)
  vim.keymap.set({ "n", "x", "o" }, lhs, function()
    ts_move[method](query)
  end, { buffer = true, desc = "fix: " .. desc })
end

ts_map("]]", "goto_next_start", "@field", "next field start")
ts_map("[[", "goto_previous_start", "@field", "previous field start")
ts_map("]}", "goto_next_end", "@field", "next field end")
ts_map("[{", "goto_previous_end", "@field", "previous field end")

ts_map("]m", "goto_next_start", "@message", "next message start")
ts_map("[m", "goto_previous_start", "@message", "previous message start")
ts_map("]M", "goto_next_end", "@message", "next message end")
ts_map("[M", "goto_previous_end", "@message", "previous message end")

ts_map("]g", "goto_next_start", "@comment", "next comment start")
ts_map("[g", "goto_previous_start", "@comment", "previous comment start")
ts_map("]G", "goto_next_end", "@comment", "next comment end")
ts_map("[G", "goto_previous_end", "@comment", "previous comment end")

With the yank mapping above, <localleader>y behaves like a Vim operator: type a motion after it to choose the FIX data to yank.

Examples:

" Yank annotated fields from the cursor through the third next field end.
<localleader>y3]]

" Yank annotated messages covered by the current line and the line below.
<localleader>yj

" Yank annotated fields in the current visual selection.
v...<localleader>y

Operator targets such as ]] must be available in operator-pending mode ("o"). The ts_map() helper above uses { "n", "x", "o" } for that reason.

Limitations

Due to a Neovim limitation, virtual lines above the first buffer line are not displayed. If the first message title is hidden, use one of these workarounds:

  • Add an empty line at the beginning of the file.
  • Press <C-b> after opening the file.
  • Set annotate.message.position = "below" or "front".

Development

Integration tests run inside a Podman container. The image includes Neovim, the tree-sitter-fix parser, and all Lua dependencies at pinned commit SHAs, so tests do not need network access at runtime.

# Build the image on first run and execute the full suite.
./bin/test-integration

# Force a fresh image rebuild after pin updates in Containerfile.
./bin/test-integration --rebuild

# Run one spec file: tests/integration/test_<name>.lua.
./bin/test-integration --filter annotate

# Open a fixture in the test image.
podman run --rm -it --entrypoint nvim \
  -v "$PWD:/plugin:Z" \
  localhost/fix-nvim-test:latest \
  -u tests/integration/minimal_init.lua tests/integration/fixtures/4.4.fix

CI runs the same image via .github/workflows/ci.yml. Host-side linting still runs with stylua and luacheck.

Links

About

A Neovim plugin for viewing and exploring FIX® Protocol (Financial Information eXchange) messages

Topics

Resources

Stars

Watchers

Forks

Contributors

Languages