fix.nvim turns raw Financial Information eXchange (FIX) logs into readable, navigable Neovim buffers. It decodes fields and messages, shows repeating-group structure, and provides focused tools for exploring and copying FIX data without changing the source text.
Before using this plugin, consider the following known Neovim limitations:
- On long FIX lines an annotation can be split mid-word across two screen rows, and cursor motions (
w,e, …) may then appear to land inside a label. This is a Neovim bug, not specific to this plugin — see neovim/neovim#35341. Workaround: use:setlocal nowrapin FIX buffers and scroll horizontally, or disable field annotations entirely (annotate.{tag,value}.enabled = false). You can still explore fields with the FIX message tree or:FIX picker. - Virtual lines above the first buffer line are not displayed (neovim/neovim#16166). Workaround: avoid
annotate.title.position = "above". - A FIX message is one very long line: with
wrapon it spans several screen rows, so a diagnostic float — which[dopens when the mapping passesfloat = true, as LazyVim's does — lands on that line's own wrapped continuation and reads as garbled text. A float andvirtual_textalways show the same diagnostic twice; short lines hide that, and in the concealed title positionsvirtual_textis narrowed to the cursor line, so the two always coincide. Workaround::setlocal nowrapas above, dropfloat = truefrom the mapping, or setvim.diagnostic.config({ virtual_text = false })and let the titles carry the diagnostics. - On very large files, Neovim's tree-sitter highlighting can freeze the UI when jumping into unparsed regions. Disable highlighting for those buffers with
:lua vim.treesitter.stop(0), or let your distribution's big-file protection handle it.
FIX buffers are detected automatically for *.fix, *.fixlog, and
*.fix.txt. tree-sitter-fix
highlights protocol fields and conceals SOH (\x01) separators as |.
Inline annotations can decode raw tags and enum values without modifying the buffer. They are disabled by default because of Nvim limitation on long FIX lines an annotation can split across screen rows and disrupt cursor motions. The screenshot shows them enabled.
Message titles remain enabled by default. They summarize each message, color its session route, and can surround, prefix, or replace the raw line.
Use :FIX annotations to toggle tag, value, title, or group annotations.
Repeating groups gain paths such as NoMDEntries/2/MDEntryPx; alternating
colors distinguish entries and nested depths.
The optional Neo-tree source presents messages, fields, and repeating groups as a hierarchy. Decoded names and values lead each label; raw tags and enum values remain visible as secondary context.
Register the source in your Neo-tree setup; fix.nvim does not call
neo-tree.setup() for you:
require("neo-tree").setup({
sources = { "filesystem", "buffers", "git_status", "fix.neo_tree" },
})Open it with :FIX tree or :Neotree fix. The tree follows the field under the
cursor, loads message fields lazily, and starts group instances collapsed. Its
actions include open/toggle, collapse, refresh, label yank, and online tag
documentation.
Run :FIX picker from a FIX buffer to search decoded fields and jump directly
to a result. This requires the optional snacks.nvim dependency.
Results stream in as large buffers are scanned.
fix.nvim bundles FIX.4.0 through FIX.5.0SP2, plus FIXT.1.1, and selects
the version from BeginString (tag 8). Add QuickFIX XML or FIX Repository
dictionaries through setup(), or switch one for the session with:
:FIX dictionary xml/custom/binance/spot-fix-oe.xml
:FIX dictionary xml/custom/coinbase/order-entry/FIX42-prod-sand.xmlLua decoders can override tag and enum labels; repository message metadata also enables repeating-group structure.
:FIX yank copies decoded fields or complete messages and supports visual
ranges, Vim operators, and named registers.
:FIX browse opens the Onixs documentation page for the tag under the cursor.
Tree-sitter queries expose fields, messages, and comments as text objects for
navigation mappings; fix.nvim intentionally installs no default mappings.
Malformed messages are flagged as you scroll — BodyLength and CheckSum out of the box, plus a structural "not a FIX message" check — and repaired through standard LSP code actions, one message at a time or the whole buffer at once. Rules are plain Lua and can be switched off or added to in the config.
Hovering a field (vim.lsp.buf.hover()) shows what the FIX dictionary knows
about it: name, type, description, the decoded value, and a reference link.
For MsgType (tag 35) the float adds the message-type description from the FIX
repository.
Formatter hooks control annotations, titles, and tree labels. Filetype rules, route colors, and plugin highlight groups are also configurable.
fix.nvim renders the viewport first, scans the rest in background batches, and
reuses parsed, decoded, and formatted results. Its size-limited persistent
cache speeds up later sessions; :FIX cache clear resets the current file.
| 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 tree |
require("fix.neo_tree").open() |
Open the FIX message tree |
: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 lsp toggle |
require("fix").lsp_toggle() |
Turn the LSP features (diagnostics, fixes, hover) on or off for every FIX buffer |
:FIX cache clear |
require("fix").cache_clear() |
Clear in-memory and on-disk cache entries for the current file, then re-render |
Pass this table as opts for your plugin manager, or pass the same fields to
require("fix").setup({ ... }). You only need to set the parts you want to
change. Active values below are defaults; commented values are optional
examples.
{
-- 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",
-- Optional custom dictionaries keyed by BeginString/FIX version.
-- Uncomment and adapt entries as needed; the default is an empty table.
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 = {
title = {
enabled = true,
position = "replace_front", -- "above" | "below" | "front" | "replace" | "replace_front"
route = {
enabled = true,
mode = "direction", -- "direction" | "sender" | "pair"
palette = {
"FixRoute1",
"FixRoute2",
"FixRoute3",
"FixRoute4",
"FixRoute5",
"FixRoute6",
"FixRoute7",
"FixRoute8",
},
overrides = {
-- { sender = "CLIENT1", target = "*", highlight = "FixClientSend" },
},
resolver = nil, -- function(route, message) return "HighlightGroup" end
},
formatter = function(message)
return require("fix.formatters.title").default(message)
end,
},
tag = {
enabled = false, -- Enable inline tag names (affected by wrapped long lines).
formatter = function(field)
return require("fix.formatters.tag").default(field)
end,
},
value = {
enabled = false, -- Enable inline enum values (affected by wrapped long lines).
formatter = function(field)
return require("fix.formatters.value").default(field)
end,
},
group = {
path = {
enabled = true, -- Show group paths in field labels.
},
highlight = {
enabled = true, -- Highlight grouped fields.
target = "both", -- "raw", "annotation", or "both"
-- Alternate colors by depth and entry.
palette = {
"FixGroupDepth1A",
"FixGroupDepth1B",
"FixGroupDepth2A",
"FixGroupDepth2B",
"FixGroupDepth3A",
"FixGroupDepth3B",
},
},
},
},
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,
},
lsp = {
enabled = true, -- The whole in-process server: diagnostics, fixes, hover.
validate = {
enabled = true,
debounce_ms = 200,
-- Built-in rules are on by default; set a rule to false to switch it off.
-- An entry with a `check` function defines a rule of your own.
rules = {
begin_string = { enabled = true },
body_length = { enabled = true },
checksum = { enabled = true },
-- unknown_tags = {
-- severity = vim.diagnostic.severity.WARN,
-- tier = 1, -- lower tiers run first and silence the ones below them
-- check = function(ctx)
-- local out = {}
-- for _, field in ipairs(ctx.message:list_fields()) do
-- if field.tag_text == nil then
-- out[#out + 1] = {
-- col = field.tag_start,
-- end_col = field.tag_end,
-- message = "Unknown tag " .. field.tag,
-- }
-- end
-- end
-- return out
-- end,
-- },
},
},
hover = {
enabled = true, -- Dictionary info for the field under the cursor via vim.lsp.buf.hover().
},
},
tree = {
summary = {
formatter = function(message)
return require("fix.formatters.tree.summary").default(message)
end,
},
field = {
formatter = function(field)
return require("fix.formatters.tree.field").default(field)
end,
},
group = {
formatter = function(group, field)
return require("fix.formatters.tree.group").default(group, field)
end,
},
},
}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
map("<localleader>t", function()
fix.annotate_toggle("title")
end, "toggle title 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")
-- Dictionary info for the field under the cursor (requires validation enabled).
map("K", vim.lsp.buf.hover, "tag info")
vim.keymap.set("n", "<localleader>y", function()
return fix.operator_yank_register("+")
end, {
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>yOperator targets such as ]] must be available in operator-pending mode ("o").
The ts_map() helper above uses { "n", "x", "o" } for that reason.
ft.extensions and ft.pattern are passed to vim.filetype.add(). Add entries
here when your FIX logs use project-specific extensions or names. Buffers with
filetype=fix are attached automatically.
fix.nvim defines the following highlight groups. Defaults follow
vim.o.background; existing user definitions are preserved.
| Group | Dark background | Light background | Used for |
|---|---|---|---|
FixRoute1 |
#4da3ff |
#005fcb |
Route palette slot 1 |
FixRoute2 |
#3ecf5f |
#007a33 |
Route palette slot 2 |
FixRoute3 |
#ffb02e |
#8a5200 |
Route palette slot 3 |
FixRoute4 |
#c678ff |
#7a2ebf |
Route palette slot 4 |
FixRoute5 |
#00c8d7 |
#007c89 |
Route palette slot 5 |
FixRoute6 |
#ff5f7a |
#b00030 |
Route palette slot 6 |
FixRoute7 |
#f0f3ff |
#334155 |
Route palette slot 7 |
FixRoute8 |
#ff7a18 |
#a13f00 |
Route palette slot 8 |
FixGroupDepth1A |
#243447 |
#e7f0ff |
Group palette slot 1 |
FixGroupDepth1B |
#243b2f |
#e6f5ea |
Group palette slot 2 |
FixGroupDepth2A |
#3a2d1f |
#fff0d8 |
Group palette slot 3 |
FixGroupDepth2B |
#332943 |
#f0e8ff |
Group palette slot 4 |
FixGroupDepth3A |
#20383c |
#e2f6f8 |
Group palette slot 5 |
FixGroupDepth3B |
#422630 |
#ffe7ee |
Group palette slot 6 |
FixTreeIcon |
Special |
Special |
FIX tree node icons |
FixTreeName |
Identifier |
Identifier |
Decoded message and field names |
FixTreeValue |
String |
String |
Decoded field values |
FixTreeMeta |
Comment |
Comment |
Raw tags, values, and scan status |
FixTreeOperator |
Operator |
Operator |
Tree separators and operators |
FixTreeGroup |
Type |
Type |
Repeating-group names |
Route groups use bold foreground colors; repeating-group groups use background colors. Group colors advance by depth and entry, then wrap through the palette. Tree groups are default links to standard Neovim groups, so colorscheme or user definitions can override them normally.
The plugin also reuses standard Neovim groups: Comment for default tag and
value annotations, Operator in the picker, and IncSearch for yank feedback.
FIX syntax highlighting uses the standard tree-sitter captures @comment,
@property, @operator, @normal, @constant, @number,
@punctuation.delimiter, and @none.
The persistent cache stores parsed and decoded messages so repeated sessions can
open large logs faster. It 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.
max_files limits the number of cache files, and max_bytes limits their total
size. Set either limit to false to disable that specific limit. 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.
render.debounce_ms waits briefly after edits before rendering, which avoids
doing repeated work while a file is still changing. render.lines_per_batch
controls the background cache warm-up chunk size. Higher values can warm the
cache faster but may make very large files feel less responsive.
Validation runs as a language server inside Neovim, so nothing extra needs
installing. Problems arrive as ordinary diagnostics — signs, ]d/[d,
vim.diagnostic.setqflist() — and repairs arrive as code actions, applied by
Neovim's own LSP client so that undo works as usual.
Three rules ship built in. begin_string reports a line that parses as
tag=value pairs but has no BeginString — a dumped repeating group, a
truncated line — as "Not a FIX message". body_length and checksum check
tags 9 and 10; they are linked, because correcting BodyLength changes the bytes
CheckSum covers, so a message wrong in both places offers a single "Fix
BodyLength and CheckSum" action. Separators are normalised, so |-, ^- and
SOH-delimited logs all validate the same.
Rules run in tiers, lowest first, and a tier that reports something stops the
ones below it — there is no point complaining about the BodyLength of a line
that is not a message at all. begin_string is tier 0; everything else,
including your own rules, defaults to tier 1.
| Action | Kind | Effect |
|---|---|---|
gra (or vim.lsp.buf.code_action()) |
quickfix |
Repair the message under the cursor or in the selection |
vim.lsp.buf.code_action({ context = { only = { "source.fixAll" } }, apply = true }) |
source.fixAll |
Repair every broken message in the buffer, as one undo step |
Rules are ordinary Lua tables keyed by id in lsp.validate.rules. A rule sees
one message and returns diagnostics, each of which may carry the fixes that
repair it:
---@param ctx FixRuleCtx { buf, lnum, line, message, scratch, opts }
---@return FixRuleDiagnostic[]|nil
check = function(ctx)
return {
{
col = 0, -- 0-based byte columns within the line
end_col = 1,
message = "something is off",
severity = vim.diagnostic.severity.WARN, -- optional; ERROR by default
fixes = {
{ title = "Repair it", edits = { { col = 0, end_col = 1, new_text = "8" } } },
},
},
}
endrequire("fix.validate").register(rule) adds or replaces a rule at runtime and
revalidates open buffers; unlike lsp.validate.rules, such rules survive a
later setup(). Rule scope is per message for now — session-level rules that
need to see the whole message stream are not supported yet.
The same server answers vim.lsp.buf.hover(): the float shows the field's
decoded name, tag, FIX data type, the dictionary description, the current value
with its enum meaning, the repeating-group path when the field sits inside one,
and an onixs.biz reference link. For MsgType (tag 35) it adds the message-type
description from the FIX repository. lsp.hover.enabled = false turns hover
off on its own; lsp.validate.enabled = false keeps hover but silences the
diagnostics; :FIX lsp toggle (or lsp.enabled = false) turns the whole
subsystem off.
Diagnostic display is not configured here; use vim.diagnostic.config() as you
would for any other server — with one exception. With
annotate.title.position = "replace" or "replace_front" the message line is
concealed, which leaves it no display width for virtual text to attach to:
vim.diagnostic would drop its text onto the row below, or lose it entirely.
In those positions the message title carries the diagnostics itself, drawn
right after it:
20251026-09:00:02 CLIENT1=>BROKER1 NewOrderSingle Buy 10 BTCUSD ■ CheckSum is 001, expected 213
Where that happens, virtual_text is switched off for this plugin's diagnostic
namespace so the same message is not drawn twice — your global
vim.diagnostic.config() and every other language server are untouched. On the
revealed cursor line in replace_front the raw message is visible, so
virtual_text keeps working there and the diagnostics render normally.
Underlines are invisible on a concealed line either way; the sign column still
works.
One more caveat: buffers with no file name get code actions but no diagnostics, because there is no URI that maps back to them; save the buffer to get them.
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.fixCI runs the same image via .github/workflows/ci.yml. Host-side linting still runs with stylua and luacheck.