Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Expressions

There are places where a fixed rule is not enough and a whole plugin is too much: how a substitution builds each replacement, what a collapsed fold says, which filetype a .h file really is. For those, bemtvi takes a small Lua expression and evaluates it where the answer is needed.

-- double every number on every line
:%s/\d+/\=tonumber(m[0]) * 2/g

-- a collapsed fold shows its first line and how much it hides
btv.fold.text([[ first:gsub("%s+$", "") .. "  (" .. lines .. " lines)" ]])

-- a .h file with templates in it is C++, not C
btv.filetype.detect([[ ext == "h" and head:find("template", 1, true) and "cpp" or nil ]])

Each one is an expression, not a function body: it evaluates to a value, and that value is the answer. There is no return, no statements, no end. (Two are blocks — the frame-time paint and the quickfix parser — because a per-line paint loops over the line’s matches and a parser builds a record, so each ends in its own return.)

The eleven surfaces

WhereIn scopeReturns
:s/…/\=…/m, lnumthe replacement text
"= / <C-r>=line, lnum, coltext to insert or paste
'foldexpr'line, lnuma fold level (see Fold levels)
btv.fold.textfirst, lines, lnuma closed fold’s collapsed row
btv.indent.exprprev, line, lnum, sw, previndentindent columns, or nil
btv.filetype.detectname, ext, heada filetype, or nil
btv.picker.scorerlabel, query, scorea sort key, higher first
btv.complete.scorerlabel, query, score, kinda sort key, higher first
btv.decor.exprline, lnumspans to draw (a block)
btv.qf.textitem, idxone quickfix row’s text
btv.qf.parseline, lnumone entry, or nil (a block)

Everything an expression needs is handed to it. That is the whole design, and the reason for most of what follows.

Substitutions

A replacement that starts with \= is an expression, evaluated once per match. m holds the submatches — m[0] is the whole match, m[1], m[2], … the capture groups — and lnum is the line the match sits on.

:%s/(\w+)_(\w+)/\=m[2] .. "_" .. m[1]/     -- one_two      -> two_one
:%s/\w+/\=m[0]:upper()/g                   -- alpha beta   -> ALPHA BETA
:%s/^/\=lnum .. ": "/                      -- number every line

A group that did not participate in the match is nil, not "", so type(m[2]) distinguishes “matched nothing” from “did not match”.

The expression is compiled once per command and called per match, and it works the same in bulk :s, in the live inccommand preview, and in the :s///c confirm walk. The delimiter still ends the replacement, so an expression containing / needs it escaped — or a different delimiter:

:%s#(\w+)/(\w+)#\=m[2] .. "/" .. m[1]#

A number is accepted and rendered as Lua prints it, so \=tonumber(m[0]) * 2 gives 14, not 14.0.

Fold levels

'foldexpr' decides a line’s fold level under foldmethod=expr. It sees the line’s own text as line and its 1-based number as lnum.

Set it through btv.bo rather than :set, since an expression contains spaces:

btv.bo.foldmethod = "expr"
btv.bo.foldexpr = "line:find('{%s*$') and '>1' or line:find('^%s*}') and '<1' or '='"

The value is vim’s fold-level grammar, and it is worth knowing because it is how you express nesting without the expression having to remember anything:

ValueMeaning
0, 1, 2, …this line is at that level
>Na fold of level N starts here
<Na fold of level N ends here
aNN deeper than the previous line
sNN shallower than the previous line
=same level as the previous line
-1undefined — take the shallower of the levels either side

The engine carries the running level from line to line, so >1 / <1 bracket a block however deeply it is nested, and your expression only ever looks at one line.

Two ready-made values need no expression at all: btv.treesitter.foldexpr folds by the syntax tree and btv.lsp.foldexpr by the language server’s foldingRange. Both are native — they never enter the sandbox.

btv.bo.foldexpr = "v:lua.btv.treesitter.foldexpr()"

Fold text

btv.fold.text replaces the +-- 12 lines: … placeholder a closed fold shows.

btv.fold.text([[ first:gsub("%s+$", "") .. "  (" .. lines .. " lines)" ]])
btv.fold.text(nil)   -- back to the built-in

first is the fold’s first line verbatim, lines how many it covers, lnum where it starts. The result is memoized on the first line, so a screen that is not changing costs nothing.

Indentation

bemtvi indents from tree-sitter’s indents.scm wherever a grammar has one. btv.indent.expr is the escape hatch for a filetype that has no grammar, or whose indent query gets a construct wrong.

btv.indent.expr([[
  line:match("^%s*end") and previndent - sw
    or prev:match("then%s*$") and previndent + sw
    or nil
]])

Return the target indent in columns, or nil to decline. Declining matters: it hands the line to smartindent/autoindent exactly as if no expression were installed, so you only have to describe the cases you care about.

The precedence is tree-sitter → your expression → smartindent → autoindent. Structure beats a hand-written rule, so an expression never overrides a grammar that has an opinion.

Filetype from content

bemtvi resolves a filetype from a file’s name, a path pattern, or its extension. Extensions that can only be settled by looking inside the file — .h for C-vs-C++, and .r, .v, .m, .pl — are deliberately left alone rather than guessed. btv.filetype.detect is where you answer them.

btv.filetype.detect([[
  ext == "h" and (head:find("template", 1, true) or head:find("::", 1, true))
    and "cpp" or nil
]])

name is the basename, ext the extension without its dot, and head the first few lines (bounded, about 2 KB). Return a filetype to decide it, or nil to decline and leave the built-in tables to answer — a returned filetype wins over them, which is what makes the .h case work.

It runs once per buffer, and its verdict becomes that buffer’s filetype exactly as :setf would set it.

Picker and completion ranking

btv.picker.scorer reorders a picker’s results. It is handed the native fuzzy score alongside the row’s label and the active query, so the natural shape is to nudge the existing order rather than reinvent matching:

-- push test files down; leave everything else where the matcher put it
btv.picker.scorer([[ score - (label:find("/test") and 50 or 0) ]])

It re-ranks only rows that already matched, and only the top 1000 of them. Matching stays native, which is what keeps a picker responsive while 100 000 candidates stream in.

btv.complete.scorer is the same thing for the completion popup, with the row’s kind also in scope ("Snippet", an LSP kind name, or "" for a buffer word):

-- keep snippets available, but below real code completions
btv.complete.scorer([[ score - (kind == "Snippet" and 20 or 0) ]])

Its score is the blended native key — the fuzzy score plus the source’s own bias (lsp 8 > snippets 5 > buffer 0) — so nudging it composes with source order rather than replacing it. Both re-rankers run at most once per repaint, never once per streamed batch, and the completion caret follows the row it was standing on: a popup that reordered under the caret would accept a candidate nobody chose.

The expression register

"= is the register whose contents are computed. It is reachable from three places, and each prompts with = for one expression:

i<C-r>=6*7<CR>          in Insert — inserts `42` at the cursor
"=lnum*10<CR>p          in Normal — the following `p` pastes the result
:e log-<C-r>=lnum<CR>   in the command line — splices the result into the line

In scope are line (the cursor’s line text), lnum and col (1-based) — vim’s expression register can reach the whole Vimscript environment, so the pure equivalent is handed what a computed insert actually wants.

A result ending in a newline pastes linewise, anything else charwise — so "="a\nb\n"<CR>p opens two new lines below.

Two consequences of it being an ordinary register once evaluated:

  • It is evaluated when you submit the prompt, not each time it is read. The text is then stored, so getreg("=") shows it and p pastes it — but "=lnum<CR>p followed by j. pastes the first line number again. (vim re-evaluates on read; bemtvi cannot, because the paste path resolves registers through a read-only borrow while the sandbox needs a mutable one.)
  • A computed insert or paste is not .-repeatable, for the same reason :s and d/foo are not: it transits the command line. . replays the last repeatable change instead.

<Esc> at the prompt computes nothing. From a nested prompt it abandons only the expression — the command line you were typing comes back untouched, as it does when the expression fails.

Painting a line

btv.decor.expr runs over every visible line during the frame and returns the spans to highlight on it:

btv.decor.expr([[
  local out, i = {}, 1
  while true do
    local s, e = line:find("TODO", i, true)
    if not s then break end
    out[#out + 1] = { s, e, "Todo" }
    i = e + 1
  end
  return out
]])

Each span is { first, last, group } with 1-based inclusive columns — what string.find hands back, so a match drops straight in. An empty list declines the line. This is the surface that takes a block rather than an expression, for the loop above.

A span can draw more than a colour. Beside the three positional slots it takes named keys, each the scalar form of the extmark key it lowers into:

virt_text          text drawn beside the line
virt_hl            its highlight group
virt_pos           where it draws: "eol" (the default), "inline", "overlay", "right_align"
hl_mode            how it merges with the cell under it: "replace" (default), "combine", "blend"
virt_lines         a whole extra screen row, or a list of them
virt_lines_hl      their highlight group
virt_lines_above   draw them above the line instead of below
sign_text          a 1-2 cell glyph in the gutter
sign_hl            its highlight group
line_hl            a highlight group backing the whole line
line_fill          a glyph repeated from the end of the line to the right edge
line_fill_hl       its highlight group
priority           where the span sits in the paint stack (default 4096)
btv.decor.expr([[
  local s, e = line:find("FIXME", 1, true)
  if not s then return {} end
  return { { s, e, "Todo", virt_text = "  <- fix me", sign_text = ">>" } }
]])

-- a heading above every `## ` line, and a rule out to the right edge
btv.decor.expr([[
  if not line:find("^## ") then return {} end
  return { { virt_lines = line:gsub("^## ", ""), virt_lines_hl = "Title",
             virt_lines_above = true, line_fill = "-", line_fill_hl = "NonText" } }
]])

The columns and the group are each optional when the span carries a decoration — a sign anchors on the line, not on a stretch of it, so { sign_text = ">>" } is a whole span. A span that draws nothing at all, or a qualifier with nothing to qualify (virt_hl without virt_text, sign_hl without sign_text), is refused rather than ignored.

priority is the span’s own, so a highlight takes one too: draw an indent guide below the treesitter layer (priority = 90) and it colours only what the syntax left plain.

It is the pure sibling of btv.decor.provider, and the choice between them is what each can see:

btv.decor.providerbtv.decor.expr
powerfull Lua, async, any editor stateline and lnum, nothing else
whenoff the frame, one frame laterduring the frame it belongs to
drawsthe whole extmark vocabulary, styled per chunkthe same decorations, one run each

Reach for the block when the paint is a pure function of the text — indent guides, colour swatches, trailing whitespace, a keyword badge — and for the provider when it needs git, an LSP reply, or anything the line alone cannot tell it.

The paint is evaluated over each window’s visible rows and memoized on the viewport, so a steady screen costs nothing and only scrolling, resizing or editing re-evaluates.

Quickfix rows

btv.qf.text renders the rows of the quickfix window — vim’s 'quickfixtextfunc'. It is the one surface handed a record: item is the entry, keyed exactly as btv.qf.getqflist() returns it, and idx is its 1-based position.

-- the message first, the location trailing
btv.qf.text([[ item.text .. "  (" .. item.filename .. ":" .. item.lnum .. ")" ]])

btv.qf.text(nil)   -- back to vim's `file|lnum col N| message`

item carries filename, bufnr, module, lnum, end_lnum, col, end_col, vcol, nr, pattern, text, type and valid.

One row per entry is a hard rule — the row index is the entry index for <CR>, :cc and the severity colouring — so a newline inside a rendered row is flattened to a space, exactly as the default flattens a multi-line message.

It applies to the quickfix list, every named list and every window’s location list, and installing or clearing it re-renders the ones that are open.

Parsing build output

btv.qf.parse turns one line of build output into a quickfix entry — the job 'errorformat' does, in Lua rather than in a mini-language inherited from a 1990s C compiler:

btv.qf.parse([[
  local file, ln, col, msg = line:match("^(%S+)%((%d+),(%d+)%): (.*)$")
  if not file then return nil end
  return { filename = file, lnum = tonumber(ln), col = tonumber(col),
           text = msg, type = msg:find("^error") and "E" or "W" }
]])

The entry may set filename, bufnr, module, lnum, end_lnum, col, end_col, vcol, nr, pattern, text, type and valid. Any other key is an error rather than an ignored one — a parser that writes line where it meant lnum would otherwise quietly produce entries that cannot be jumped to.

Returning nil declines the line, which is not the same as dropping it: like 'errorformat', a line no pattern matched is kept as an invalid entry carrying its raw text, which is what makes :copen show a build’s prose alongside its errors.

It applies wherever 'errorformat' does — :cbuffer, :cfile, :cexpr, setqflist{ lines = … }, :make, :grep — and replaces that pass while it is installed, because two parsers disagreeing about one line has no sensible answer.

What it cannot do. The block sees one line and carries nothing between calls, because the sandbox is stateless. That rules out errorformat’s multi-line entries (%A/%C/%Z) and its directory stack (%D/%X) — exactly the features that need an accumulator. 'errorformat' stays for those, and stays the default.

What an expression can do

The expression runs in a second, deliberately tiny Lua VM. It has the value-level standard library — string, table, math, utf8, plus tostring, tonumber, type, pairs, ipairs, next, select, assert, error — and nothing else.

There is no io, os, package, require, load, dofile or debug, no coroutines, and no btv.*. An expression cannot read the buffer, touch the filesystem, or reach the editor at all. Everything it is allowed to know is in its arguments.

Why source text, not a function

You pass a string of Lua rather than a function:

btv.fold.text([[ first .. " (" .. lines .. ")" ]])   -- yes
btv.fold.text(function(first, lines) end)            -- fails loudly

The expression runs in a different Lua VM from your config, and a closure cannot cross between VMs — it would carry references to everything it captured. The source crosses instead, and is compiled on the other side.

Expressions are stateless

Nothing carries from one call to the next, and assigning a global raises an error. That is deliberate, and the reason is that none of these surfaces calls your expression once per item in order:

SurfaceWhy a counter would lie
:sthe live preview re-runs it on every keystroke you type
'foldexpr'only the rows an edit touched are re-evaluated
btv.picker.scoreronly the top survivors, re-run on each repaint
btv.complete.scorerthe same, over the popup’s rows
btv.decor.expronly the rows on screen, and only when the viewport moved
btv.fold.textmemoized, so calls are skipped outright
btv.filetype.detectonce per buffer
"= / <C-r>=evaluated once per prompt, then stored

A counter over :%s/x/…/g on a three-x line would read 16 17 18, not 1 2 3, because the preview got there first. Statelessness turns that class of bug into an error you see immediately.

Fold nesting, the one case that genuinely wants carried state, is served by the relative fold values above — the engine does the accumulating.

When an expression fails

Every failure is reported, and none of them silently produces an empty value.

A compile error is caught when you configure the expression, before it can touch anything. A runtime error, a wrong return type (a table where a string was wanted), or an expression that runs longer than its 50 ms budget aborts that use.

Surfaces that run continuously — fold text, indentation, filetype, picker ranking — report the failure once and then uninstall the expression, falling back to the built-in behaviour. An expression evaluated on every repaint cannot be allowed to print the same error on every frame.

:s and the expression register are the exceptions, because they are one-shot. A failing "= echoes, computes nothing, and leaves whatever was stored alone. A failing :s expression aborts the substitution and says so: a compile error leaves the buffer untouched; a failure partway through leaves the substitutions already made, which one u undoes, since the whole run is a single undo step.

An expression that loops forever is stopped at its deadline rather than hanging the editor, and the VM has a hard memory ceiling, so string.rep("x", 1e12) fails the call instead of the process.