██╗ ██╗██╗███╗ ███╗██╗ ██████╗ ███████╗
██║ ██║██║████╗ ████║██║ ██╔══██╗██╔════╝
██║ ██║██║██╔████╔██║██║ ██████╔╝███████╗
╚██╗ ██╔╝██║██║╚██╔╝██║██║ ██╔══██╗╚════██║
╚████╔╝ ██║██║ ╚═╝ ██║███████╗██║ ██║███████║
╚═══╝ ╚═╝╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝╚══════╝
VimL (Vimscript) in Rust — the first compiled standalone VimL interpreter,
run outside Vim. A faithful port of Neovim's C eval engine, hosted on the
fusevm bytecode VM with a
three-tier Cranelift JIT — the same engine behind zshrs, stryke, awkrs, and elisp.
What it is
Vimscript has only ever run embedded inside Vim or Neovim. vimlrs takes the
eval engine out of the editor and runs .vim scripts as ordinary programs. The
language semantics are ported faithfully from Neovim's C eval/* tree — the C
source is the spec — rather than re-invented.
It is the fourth language hosted on fusevm. vimlrs carries no VM or JIT of its
own: it lexes and parses VimL to an AST, lowers that to fusevm bytecode, and lets
the shared engine run it — the same way zshrs hosts zsh.
VimL source → lexer → parser (AST) → lower to fusevm bytecode → fusevm VM + Cranelift JIT
Status
In active development.
| Component | State |
|---|---|
Value layer — typval, list, insertion-ordered dict (typed tv_dict_get_*/tv_dict_add_*, tv_dict_add fail-on-dup), blob |
Ported |
Coercions + typval_compare + num_divide/num_modulus |
Ported |
string() / :echo rendering (encode_tv2string/encode_tv2echo) |
Ported |
Lexer / parser → AST (the eval1…eval7 grammar) |
Working |
| AST → fusevm bytecode lowering | Working |
| Runs on fusevm's 3-tier Cranelift JIT | Working — JIT enabled; integer +/-/* → native Op::Add/Sub/Mul, integer compares → Op::NumLt/…; an integer expression block-JIT-compiles to machine code, and a function's numeric while loop (provably-Number l: locals → Op::GetSlot/SetSlot, loop rotated so the condition is the backedge) trace-JIT-compiles to native code — both verified by tests. Dynamic ops stay CallBuiltin (the deopt fallback). |
Idiomatic for i in range(N) → native integer counter loop (no list built) that trace-JIT-compiles |
Working (1/2/3-arg range(); the bound may be dynamic — range(a:n)/range(len(x)) hoist a tv_get_number-coerced bound once in the prologue, so the body still traces; verified) |
| Numeric loops trace-JIT at both function and script (top-level) scope | Working — slot_plan slots provably-Number locals; explicit l:name refs in a function share the bare slot (l: is the local scope), while a name with a g:/s:/a:/… alias stays dict-backed |
Float arithmetic + float-accumulator loops trace-JIT too (native fadd; int counter + float accumulator in one trace) |
Working |
Compound loop conditions (&&/` |
|
Per-loop slot scoping: a hot loop traces even when the function also calls helpers (callees can't see l: locals) or runs a sibling list-for |
Working (function scope; script-scope calls still bail, since bare = g:) |
Native integer % (e.g. if i % 2 == 0) so modulo loops trace; / stays on the builtin (fusevm div is float, unlike VimL integer /) |
Working |
Native numeric negation (-x → Op::Negate); VIMLRS_JIT_STATS counts function-body loops too |
Working |
Native bitwise builtins (and/or/xor/invert of integer args → Op::BitAnd/BitOr/BitXor/BitNot) so bit-manipulation loops trace |
Working |
Numeric ternary (cond ? a : b) — the test lowers through the native condition path and a numeric ternary is itself a Number, so s += cond ? x : 0 loops trace |
Working |
Value-position comparison (let s += i > 5) — native compare reified to VimL's 0/1 with a branch (no CallBuiltin), so counting loops trace |
Working |
Logical-not of an integer (!x / !(i % 2)) → native x == 0 reified to 0/1, so it stays trace-eligible |
Working |
Observable from the real CLI: VIMLRS_JIT_STATS=1 viml script.vim reports loop traces compiled; VIMLRS_NO_JIT=1 forces the interpreter baseline |
Working — a 20M-iteration loop runs ~15–100× faster with the JIT |
Native Op::ReturnValue (whole function bodies block-compile) + per-loop (not per-chunk) slot scoping |
In progress (next) |
| Expression engine — arithmetic, comparison, logic, ternary, index/slice, lists/dicts | Working |
| Builtin function surface | Partial (len/type/string/empty/abs/str2nr/str2float/float2nr; full funcs.c pending) |
Standalone viml binary (-e / -c / file / --repl) |
Working |
Interactive REPL (viml --repl, or bare viml in a terminal) — reedline line editor with a live ASCII stats banner, Tab completion (the LSP wordlist), ~/.vimlrs/history, and emacs/vi edit mode (~/.vimlrs/config.toml [repl] mode, VIMLRS_REPL_MODE override). Piped/non-TTY stdin falls back to the line-oriented reader. |
Working |
rkyv bytecode script cache (~/.cache/vimlrs/scripts.rkyv, mmap zero-copy) |
Working |
AOT build (--build bakes scripts into a self-contained executable) |
Working |
Bytecode disassembler (--disasm) |
Working |
LSP server (--lsp) — diagnostics, completion, hover, document symbols |
Working |
DAP debugger (--dap) — breakpoints, stepping, variables, evaluate |
Working |
Control flow — :if/:elseif/:else, :while, :for, :break/:continue |
Working |
:execute, :let [a, b; rest] = … & :for [k, v] in … destructuring |
Working |
:let compound assignment (+=/-=/*=//=/%=/.=) — desugars to target = target op rhs, so accumulator loops trace-JIT |
Working |
:let index/member assignment (let d['k']=v, let d.k=v, let l[i]=v, compound forms) — Dict/List/Blob element set; Dict-set fires dictwatcheradd() watchers |
Working |
| command separator (let l = [1] | echo l) — strings/||/\\|/comment-aware |
Working |
User functions — :function/:return, recursion, a:/l: scopes |
Working |
vim9script foundation — :vim9script marker, def NAME(p: type, …): rettype … enddef with bare (a:-less) parameters + optional defaults, and vim9 automatic line continuation (unclosed []/{}/(), leading/trailing binary operators, ->/./?/:, # comments) — examples/vim9_def.vim self-tests vs vim 9.2 |
Working — type checking, bare-key {k: v} dict literals, :class, import/export deferred |
Variable scopes — g:/s:/b:/w:/t:/v: + :set/&opt ('ignorecase' wired into regex) |
Working |
:try/:catch/:finally/:throw exceptions, v:exception |
Working |
funcs.c builtin table |
In progress (~113 ported: string/list/dict, char-indexed string ops (slice/strcharlen/strtrans/strwidth/strdisplaywidth/charclass/strutf16len/utf16idx), glob/globpath, buffer/window introspection (bufnr/winnr/tabpagenr, editor-absent), float math + isinf/isnan, regex, eval/execute, json_encode/json_decode, env (getenv/setenv/environ), system/systemlist (shell out, sets v:shell_error), shellescape, getpid/localtime/soundfold, reltime/reltimestr/reltimefloat, rand/srand (xoshiro128**, bit-exact vs Neovim), strftime/strptime, pathshorten, flattennew, sha256 (FIPS-180-2), list2blob/blob2list (+ blob index/slice), …) |
map/filter/sort/reduce/call (lists and dicts; string-expr + funcref) |
Working |
Unit-testing framework — assert_equal/assert_notequal/assert_true/assert_false/assert_match/assert_notmatch/assert_report/assert_inrange/assert_exception → v:errors, plus assert_fails (run a command, require it to error/match a code) — message wording per eval.lua |
Working — every examples/*.vim is a self-test, run in CI via tests/examples.rs |
eval() / execute() (run-string metaprogramming) |
Working |
Regex engine — Vim magic dialect, backing =~/matchstr/match/substitute/split/:catch |
Working |
Regex char-class atoms are ASCII-only per :help /\a — \a/\l/\u/\w/\d/\x (+ negations) reject multibyte letters/digits (é, À, Ω, 4); only \</\> word boundaries follow multibyte 'iskeyword' — examples/regex_classes.vim self-tests vs nvim/vim |
Working |
Option-derived regex atoms — \h/\H head-of-word [A-Za-z_], \o/\O octal [0-7] (true negations), plus \p/\i/\k from default 'isprint'/'isident'/'iskeyword' with their \P/\I/\K "excluding-digits" forms (NOT set-complements, per :help /\P); \p is printable incl. multibyte, \i is single-byte only (é yes, Ω no), \k is multibyte-aware (é, Ω, 中) — examples/regex_atoms.vim self-tests vs nvim/vim. \f/\F ('isfname') skipped: default is platform-conditional in Vim's C source |
Working |
POSIX bracket classes inside [...] per :help /[:alpha:] — the standard set ([:alnum:] [:alpha:] [:blank:] [:cntrl:] [:digit:] [:graph:] [:lower:] [:print:] [:punct:] [:space:] [:upper:] [:xdigit:]) plus Vim extras [:tab:]/[:escape:]/[:backspace:]/[:return:]/[:ident:]/[:keyword:]. ASCII-ness is not uniform: [:alpha:]/[:alnum:]/[:digit:]/[:graph:]/[:punct:] are ASCII-only, but [:lower:]/[:upper:] are Unicode-case-aware (é/À/Ω match, unlike ASCII-only \l/\u), [:print:] is multibyte-aware, and [:space:] includes vertical-tab (0x0B) which \s omits. Classes compose with ranges/literals and negate ([[:digit:]a-f], [^[:alpha:]]). [:fname:] skipped ('isfname' platform-conditional, like \f) — examples/regex_posix.vim self-tests vs nvim/vim |
Working |
Case-fold (\c/\C and 'ignorecase') folds only LITERAL set members, not case-defined predicates — literal atoms (\ca), bracket literals ([abc]), ranges ([A-Z]/[a-z], negated too) match either case under \c, but POSIX [[:upper:]]/[[:lower:]] and atoms \u/\l keep their definition (a lowercase char never matches [[:upper:]] under \c); case-agnostic \d/\w/\a/\x are no-ops and \C forces case-sensitive — examples/regex_ic.vim self-tests vs nvim/vim |
Working |
Substring/width builtins — byte-indexed strpart vs char-indexed strcharpart/strgetchar, strlen/strchars/strwidth/strdisplaywidth, nr2char/char2nr (multibyte + astral emoji round-trips) — examples/substr_funcs.vim / examples/strwidth_funcs.vim self-test vs nvim/vim |
Working |
String building — literal-pattern substitute, tr, repeat (string + list), split (literal sep + keepempty flag), join — examples/strmanip.vim self-tests vs nvim/vim |
Working |
substitute() Vim quirks — global empty-match handling per do_string_sub's zero_width rule (an empty match at the previous empty-match position is skipped, so a* over aaa gives X not XX; x* over abc still gives -a-b-c-) + vim_regsub replacement specials where \n inserts a NUL (0x00), \r a carriage return, \t a tab, \\ a backslash — examples/substitute_edge.vim self-tests vs nvim/vim |
Working |
reduce() left fold (seeded/unseeded, numeric/string/list accumulators) + positional list extend(l, l2, idx) — examples/reduce_fold.vim self-tests vs nvim/vim |
Working |
printf()/%s/%S stringify containers — a List/Dict/Funcref argument renders as its string() form ([1, 2, 3], {'a': 1}, type) via tv_str→encode_tv2echo instead of raising E730 — examples/printf_containers.vim self-tests vs nvim/vim |
Working |
Value introspection — type()/empty()/len() across Number/String/Funcref/List/Dict/Float/Bool/Special/Blob; abs/ceil/floor/trunc/round (half-away-from-zero)/float2nr — examples/typeintro.vim / examples/numround.vim self-test vs nvim/vim |
Working |
Logical operators &&/` |
|
Implicit String→Number coercion in arithmetic — leading-integer parse with 0x/0X/0b/0(octal) prefixes, fractional/scientific tail dropped, non-numeric/whitespace-led → 0; plus abs/min/max over lists and dict-values — examples/coerce_arith.vim self-tests vs nvim/vim |
Working |
Byte-offset string search — stridx/strridx (literal, first/last, {start}), match/matchend (regex start/end offset), matchstr ({start}), matchstrpos (['',-1,-1] miss shape), matchlist — examples/str_search.vim self-tests vs nvim/vim |
Working |
funcref() builtin over user functions — direct call, call() interop, leading-List Partial pre-bind, and Funcrefs stored in List/Dict elements invoked via bracket index — examples/funcref_builtin.vim self-tests vs nvim/vim |
Working |
Date/time builtins — strftime formats an epoch second, strptime parses one back; TZ-independent strptime→strftime round-trips (full stamps, leap day, partial reformat), literal %%/empty format, and result-type checks — examples/date_format.vim self-tests vs nvim/vim |
Working |
Transcendental float math — sqrt/exp/log/log10, trig sin/cos/tan + inverse asin/acos/atan/atan2 (quadrant-aware), hyperbolic sinh/cosh/tanh, verified via float2nr(round(x*1e6)) fixed-point + forward/inverse identities — examples/math_trig.vim self-tests vs nvim/vim |
Working |
In-place list editing — add/insert (default-prepend + index), remove (single index, negative index, and index-range slice return), count ({ic,start} window), index ({start,ic}), and range() (end/start,end/start,end,stride) — examples/list_edit.vim self-tests vs nvim/vim |
Working |
String↔number conversion — str2nr (bases 2/8/10/16 with 0x/0b prefixes, sign, leading space), str2float (decimal/scientific), char2nr/nr2char multibyte codepoint round-trips, trim (default whitespace, custom mask, dir leading/trailing), escape — examples/str_numconv.vim self-tests vs nvim/vim |
Working |
:source {file} (functions/globals persist) + autoload (foo#bar() sources autoload/foo.vim on demand) |
Working |
Lambdas {args -> body} (with closure capture), funcref-variable calls F(args), Blob literals 0z…, d.key member read, #{key: val} literal-key Dicts, \ line continuation |
Working |
one-line block bars — if … | … | endif (and for/while), incl. after a leaf command (let x=1 | if x | … | endif) |
Working |
variadic functions (... -> a:000/a:0), :unlet, :source, autoload |
Working |
:command/:autocmd (user commands + :doautocmd event firing) |
Working — examples/user_commands.vim / examples/autocommands.vim self-test in CI |
Blob index/slice operators + arithmetic — element index → unsigned byte, INCLUSIVE [a:b] sub-Blob (with open/negative ends), + concatenation, ==/!= content compare, string() 0z-literal render, type() → v:t_blob (10), and get() byte-or-default — examples/blob_bytes.vim self-tests vs nvim/vim |
Working |
sha256() SHA-256 hex digest — FIPS-180-4 empty/abc vectors, longer ASCII, UTF-8 multibyte, 1000-byte multi-block, plus 64-char/lowercase/deterministic/avalanche invariants — examples/sha256_digest.vim self-tests vs nvim/vim |
Working |
Dictionary copy + extend collision policy — shallow copy() (nested containers shared) vs recursive deepcopy() (fully independent), extend force/keep/error (E737) actions, and extendnew() leaving both args intact — examples/dict_deepcopy.vim self-tests vs nvim/vim |
Working |
count() across container kinds — non-overlapping substring count on Strings, value-match count on Dicts, element count on Lists, with the ic case-fold flag and multibyte substrings — examples/count_types.vim self-tests vs nvim/vim |
Working |
The full interpreter C surface is scaffolded: scripts/gen_port_stubs.sh
generates one stub per not-yet-ported Neovim C function (real name +
vendor/<file>:<line> citation) under src/ported/stubs/, so the remaining work
is enumerated and the drift gate covers it. Functions drop out of the stub tree
as they are faithfully ported.
Porting discipline (exact C names, // c:NNN citations, two-zone src/ported/
vs crate-root carve-out layout, the stub surface) is documented in the
Port methodology section of docs/report.html.
Building
fusevm is pulled from crates.io with the jit and jit-disk-cache features.
The vendored Neovim C eval sources under vendor/ are the porting spec and are
excluded from the crate build.
Links
- Docs — https://menketechnologies.github.io/vimlrs/
- Engineering report — https://menketechnologies.github.io/vimlrs/report.html
- The shared VM —
fusevm
License
MIT. See LICENSE.