README.md

September 3, 2026 · View on GitHub

████████╗ ██████╗██╗     ██████╗ ███████╗
╚══██╔══╝██╔════╝██║     ██╔══██╗██╔════╝
   ██║   ██║     ██║     ██████╔╝███████╗
   ██║   ██║     ██║     ██╔══██╗╚════██║
   ██║   ╚██████╗███████╗██║  ██║███████║
   ╚═╝    ╚═════╝╚══════╝╚═╝  ╚═╝╚══════╝

Rust license status

[TCL, COMPILED TO BYTECODE — LOWERED ONCE, NOT RE-PARSED PER EVALUATION]

"tclsh interprets Tcl. tclrs compiles it to fusevm bytecode."

Tcl in Rust — a Tcl frontend that parses Tcl source and lowers it to fusevm bytecode, the shared execution engine behind zshrs, stryke, awkrs, vimlrs, elisprs, rubylang, pythonrs, phplang, node-js, rlang, go-rs, and the JVM frontends. No bespoke VM. No interpreter loop and no code generator in this crate — those belong to the VM.

The reference implementation is tclsh 9.0.4. It is the specification: behavior is ported from it, not reinvented, and the test suite compares against it directly rather than against expectations written by hand.


Table of Contents


[0x00] OVERVIEW

Tcl 9 evaluates through a bytecode engine wrapped around a dual-representation object model, re-deriving string representations as values cross command boundaries. tclrs takes a different path: it parses a script once — resolving every substitution the grammar permits at parse time — and lowers each command to fusevm bytecode, the same bytecode sixteen other language frontends emit.

  • Compiled, not re-parsed — a braced body is fully known at parse time, so if / while / for bodies and braced expr expressions compile once into bytecode instead of being re-parsed on every evaluation. Words carry a braced flag for exactly this decision.
  • fusevm-hosted — no local vm.rs / jit.rs, no bespoke object heap. Tcl strings, integers and floats map onto fusevm::Value directly; a value produced as a number stays a number in a VM slot and only acquires a string representation when something asks for one.
  • Native arithmetic+ - *, the comparisons, the bitwise and shift operators, and the short-circuiting && / || lower to native fusevm ops. Only the operators whose Tcl meaning differs from the VM's generic one — /, %, ** — take a frontend extension op, and only operands the VM cannot compute on natively (mostly strings), plus the one pair it could compute on but must not — an integer past 2532^{53} against a double, which Tcl orders exactly — take the numeric hook.
  • One driver for everything — procedure calls, catch unwinding, coroutine switching and nested eval all go through a single driver that owns the interpreter's variables and installs every VM hook in one place.
  • Compiled ahead of timetclrs --aot out script.tcl lowers a script through fusevm's closed-world compiler to a native object and links it into a standalone executable with no parser and no bytecode dispatch loop inside it.
  • JIT armed, and honest about it — every VM this crate builds enables fusevm's Cranelift tiers, and tclrs --tiers reports which of them a given script actually reaches. A hot loop inside a procedure reaches a compiled trace: 3,000,000 iterations of while {$i < $n} {incr i} in 6.6 ms against 243.7 ms interpreted. The same loop at a script's top level reaches nothing, because a top-level variable is a VM global. Both halves are measured, and both are named precisely: see JIT Compilation.
  • Differentially tested — every program in the suite is executed by both tclsh and tclrs and the output compared byte for byte. No expected output in this repository is written by hand.

[0x01] BUILD

A release tag publishes a prebuilt tclrs for macOS (arm64, x86_64) and Linux (x86_64, aarch64) and bumps the tap formula, so a binary install is one command:

brew tap MenkeTechnologies/menketech
brew install tclrs

From source:

git clone https://github.com/MenkeTechnologies/tclrs
cd tclrs
cargo build
cargo test

Requires a stable Rust toolchain, and a C compiler for --aot to link with. A script containing a rust { ... } block needs rustc at run time as well, since the block is compiled when the script is.

cargo build produces three artifacts: the tclrs binary, the tclrs rlib, and libtclrs.a — the staticlib an ahead-of-time object links against.

The differential tests invoke tclsh (or tclsh9.0 / tclsh8.6) from PATH and report a skip when none is installed, so the suite still runs on a machine without Tcl.


[0x02] THE BINARY

tclrs FILE ?arg ...?        run a script file
tclrs -c SCRIPT ?arg ...?   run SCRIPT
tclrs                       read from stdin; a REPL when stdin is a terminal
tclrs --version             print the version    (also -V)
tclrs --help                print the usage      (also -h)
tclrs --tk FILE ?arg ...?   run on the main thread, with Tk available

--tk exists only in a build with the tk feature (cargo build --features tk), and is refused as an unknown option in a default build. It does two things: it runs the interpreter on the process main thread — Tk on macOS panics otherwise (tk9.0.4/macosx/tkMacOSXNotify.c:258-272) — on a 256 MiB stack the binary maps itself, and it opens a Tk session before the script is compiled, so that a widget command Tk has not registered yet is lowered as a run-time lookup rather than as invalid command name.

package require Tk in a default build is can't find package Tk, which is what tclsh says about a package it cannot locate. In a --features tk build outside a --tk session it names the reason instead — the toolkit is present but cannot be initialised off the main thread — rather than claiming it is missing.

Nothing is loaded until the script says package require Tk. That is what dlopens the toolkit, calls Tk_Init and registers Tk's commands into the interpreter the script is running in; a --tk run of a script that never asks for Tk never opens the dylib. Tk's script library is found from that dylib rather than from this binary: the directory the dynamic linker mapped it into is put on auto_path before Tk_Init runs, so tcl_findLibrary tk reaches the tk9.0/tk.tcl of the install actually in use with no TK_LIBRARY in the environment. TK_LIBRARY and TCL_LIBRARY still outrank it. When the script has finished, the binary enters Tk's own main loop if Tk registered one — the same thing wish does, and under the same condition, which is that the script succeeded (generic/tclMain.c:589-598).

Shell completion is completions/_tclrs — put that directory on fpath. The manual pages are man/man1/tclrs.1 and the all-in-one man/man1/tclrsall.1: man ./man/man1/tclrsall.1.

tclsh is the specification for what the binary prints and what it exits with.

BehaviorWhat happens
A script fileOne script. The first failure ends it: the message goes to stderr, followed by (file "…" line N) when the failure was located while compiling, and the process exits 1.
Stdin, not a terminalA sequence of commands. Each is evaluated as it completes, a failure is reported on stderr and the next command still runs, and end of input exits 0 — which is why tclrs < script exits 0 where tclrs script exits 1.
Stdin, a terminalThe same evaluation, driven by a line editor: prompt, history, completion, multi-line editing, and the value of each command echoed. See The REPL.
argv0, argc, argvSet before the script runs, as tclsh sets them.
Errorsstderr only. No banner, no prompt outside a terminal, and no output the binary produces that the script did not ask for.

An unknown option is refused (tclrs: unknown option "--wat") rather than treated as a file name — the one place this binary deliberately differs from tclsh, which reads stdin for any argument starting with -.

The REPL

A terminal gets a reedline line editor. A pipe does not: tclrs < script is still the silent loop, byte for byte.

─( 14:52:07 )──< command 3 >──────────────────────{ tclrs 0.3.0 }─
tclrs❯ proc double {x} {
····❯   expr {$x * 2}
····❯ }
tclrs❯ double 21
42
Multi-line editingA command left open keeps the editor on the same buffer. What counts as open is the parser's own answer — the Validator is repl::incomplete and nothing else — so the editor and the evaluator cannot disagree about where a command ends. Text that is malformed rather than unfinished is evaluated, and its error reported, instead of hanging the prompt.
CompletionTab offers what the compiler would accept in that position: command names at the head of a command, an ensemble's subcommands after string / array / dict / info, this session's procedures, and the interpreter's variables after $. The vocabulary is assembled from the compiler's own tables (src/names.rs), and a test fails if a name is offered that the compiler does not know.
ProceduresA procedure is compiled into the chunk of the script that defines it, so it would otherwise last exactly one line. The REPL keeps the text of each definition and prefixes the set to every later evaluation, which is what makes double answer on the line after it was written. Writing a definition again replaces the earlier one. Coroutines are not carried this way — replaying coroutine would run its body again.
History~/.tclrs/history, 5,000 commands, shared across sessions.
KeysEmacs by default. TCLRS_REPL_MODE=vi, or mode = "vi" under [repl] in ~/.tclrs/config.toml, switches to modal editing; Tab and Shift-Tab drive the completion menu in either.
Leavingexit, exit N, quit, or Ctrl-D. Ctrl-C abandons the line being typed.

Options that do not run the script the ordinary way

tclrs --aot out script.tcl          # compile to a standalone native executable
tclrs --aot-object out.o script.tcl # emit the relocatable object only
tclrs --tiers script.tcl            # run it, then report which fusevm tiers took it
tclrs --dump-tokens script.tcl      # print the parser's lexical output
tclrs --dump-ast script.tcl         # print the parse tree
tclrs --disasm script.tcl           # print the compiled bytecode instead of running it
tclrs --lsp                         # speak the Language Server Protocol on stdio
tclrs --dap                         # speak the Debug Adapter Protocol on stdio

The two dumps are the parse made visible. Tcl has no lexer to print — a word's substitutions are decided while it is read — so --dump-tokens prints the parts of each word in the order they were read, under the shape of the word that decides whether they are substituted at all:

$ tclrs --dump-tokens -c 'puts "x is $x"'
line word  kind     value
   1    1  bare     puts
   1    1  · lit    puts
   1    2  quoted   x is $x
   1    2  · lit    x is
   1    2  · var    x

--dump-ast prints the same parse as the tree it is, with a command substitution nested inside the word that contains it.

Each of those wants a whole script before it does anything, so it reads a file, a -c argument, or all of stdin, and never opens a REPL. --lsp and --dap are the exception: stdio carries the protocol, so neither takes a script there — the language server is sent the document's text, and the debug adapter opens the file its launch request names.

The language server

tclrs --lsp speaks the Language Server Protocol on stdio. Point an editor's Tcl client at it — the binary needs no configuration and no workspace.

CapabilityWhere the answer comes from
DiagnosticsThe parser's failure, then the compiler's, republished on every edit. A construct this frontend refuses is a diagnostic, so the editor reports what running the file would report rather than what full Tcl allows.
CompletionCommand names at the head of a command, an ensemble's subcommands after string / array / dict / info, and the document's own procedures. The list is the compiler's tables (src/names.rs).
Hover, signature helpThe synopsis — the wording of the command's own wrong # args message — and a one-line summary. An ensemble answers with its subcommand's synopsis.
Document symbolsThe proc commands the parser found.

What is under the cursor is decided by src/cursor.rs, the module the REPL's completer uses, so the editor and the prompt agree. tests/lsp_session.rs drives the real binary over the wire: handshake, unsolicited diagnostics, edits, and a shutdown that exits.

Inline Rust

A rust { ... } block compiles to a shared library and its exports become Tcl commands:

rust {
    pub extern "C" fn add(a: i64, b: i64) -> i64 { a + b }
}
puts [add 21 21]        ;# → 42

rust { is not a Tcl command, so the block never reaches the parser: the source is rewritten first into __rust_compile <base64> <line>, padded to keep the line count so a later error still points where it was written. The compiling, dlopening and marshalling belong to fusevm::ffi; the library is cached under ~/.cache/fusevm/ffi by the SHA-256 of the block's body (FUSEVM_FFI_DIR relocates it), so the second run of a script does not call rustc.

Registration happens while compiling, not while running. This frontend resolves dispatch at compile time wherever the script's own text decides it — a name is a builtin, a top-level procedure, a coroutine, an exported Rust function, or unknown before the VM starts — so the block is compiled and registered as its command is lowered, which is what makes add a known name by the next line. A procedure of the same name still wins: dispatch asks the script's own definitions first. The names the text cannot decide — a procedure defined by a proc away from the top level, a procedure another chunk defined, a command Tk registers during Tk_Init, and a command whose own name is written {*}$cmd — resolve in a run-time command table instead; see Procedures.

Signatures are fusevm's marshalling set: up to four i64 arguments returning i64, up to three f64 returning f64, and *const c_char returning either i64 or *const c_char (c_char, CStr and CString are already in scope inside a block). Anything else is not exported, and the block is refused for having no exports.

The debugger

tclrs --dap speaks the Debug Adapter Protocol on stdio: breakpoints, stepping, stack frame, variables, and the program's output as output events.

Stopping is compiled in, not interpreted around. compiler::compile_debug emits an ext_wide::DBG_LINE marker before every command, and the marker's handler stops when a breakpoint matches, when the client is stepping, or when a pause was asked for. Three consequences worth knowing:

  • A debugged script runs the same bytecode a plain run does, plus the markers. There is no second lowering, and no interpreter written for the debugger.
  • An ordinary compilation carries no markers at all, so nothing is paid for a debugger that is not attached.
  • Markers go into procedure bodies too, which is what makes a breakpoint inside a procedure reachable and lets a step walk into one. A command substitution gets none — set out [double 21] is one step, not two.

The run happens on the adapter's own thread, and requests are served from inside the stop, so variables reads the paused VM rather than a snapshot of it. The cost is that an asynchronous pause lands at the next command rather than mid-command; stepIn and next both stop at the next command, and stepOut resumes to the next breakpoint.

Environment

TCLRS_JIT=off (or 0, or no) skips arming the JIT. It exists so the benchmark can measure the interpreter and the JIT-armed VM as separate rows of the same binary. TCLRS_REPL_MODE=vi picks the REPL's keymap. TCLRS_STATICLIB points an --aot link at a libtclrs.a somewhere other than the build's own.

fusevm's own knobs work unchanged: FUSEVM_JIT_BLOCK_THRESHOLD, FUSEVM_JIT_TRACE_THRESHOLD, FUSEVM_JIT_CACHE_DIR and FUSEVM_FFI_DIR.

A --features tk build reads three more. TCLRS_LIBTK is the Tk dylib to open, instead of the Homebrew paths tried by default. TCLRS_TK_TRACE puts the stub-call log back on stderr — one line per call into the host, which is the probe's instrument and is off in a session because Tk_Init alone serves 2737 of them — along with one tkinit line reporting what Tk_Init returned. TCLRS_TK_STRINGS logs the strings that crossed the boundary.


[0x03] THE LIBRARY

tclrs::eval compiles and runs a script in a fresh interpreter, returning its value and everything it wrote to stdout:

let out = tclrs::eval("set x 5\nputs [expr {$x * 2}]").unwrap();
assert_eq!(out.output, "10\n");

tclrs::Interp is the same thing with the state kept between calls, which is what a REPL needs and what the eval command needs:

let mut interp = tclrs::Interp::capturing();
interp.set_global("argv", "a b c");
interp.eval("set total 0").unwrap();
interp.eval("foreach x {1 2 3} {set total [expr {$total + $x}]}").unwrap();
assert_eq!(interp.global("total").as_deref(), Some("6"));
EntryWhat it is for
Interp::newScripts write to the process's stdout, through one buffered writer flushed at the end of each evaluation.
Interp::capturingScripts' writes are collected for Interp::take_output.
Interp::set_global / Interp::globalHost access to the interpreter's variables.
Interp::set_recursion_limitHow deep eval may nest. The default is DEFAULT_RECURSION_LIMIT (1000), which needs RECOMMENDED_STACK (256 MiB) of thread stack; the binary spawns a thread that size. Nesting deeper is a script error, never a stack overflow.
parser::MAX_NESTING_DEPTHHow deeply command substitutions and array indices may nest in the input, which is the parser's own recursion — 64_000, measured against the stack above. Deeper is a script error, for the same reason: an exhausted stack is a signal with nothing to report.
Interp::cache_stats(hits, misses) from the source-keyed chunk cache — one miss per compilation, so the same eval text in a loop is lowered once.
tclrs::eval_capturedeval for a caller that wants both halves of a failing run: the error and whatever the script had already printed.
tclrs::parseThe parsed Script without running it, for tooling that wants the word structure.
tclrs::aotcompile_object, compile_executable, and run_native — the same codegen driven in-process.
tclrs::tiersreport and inspect: which fusevm tiers a chunk reaches.
tclrs::dumptokens and ast: the two listings --dump-tokens and --dump-ast print.
tclrs::lsprun_stdio for the whole server, or diagnostics, completion, hover, signature_help and document_symbols one answer at a time, for a host that already owns the transport.
tclrs::daprun_stdio: the debug adapter, over stdio.
tclrs::cursorword_at and context_at: what a position in a line is inside, which is how the REPL and the language server agree about it.

[0x04] LANGUAGE SURFACE

Commands

GroupCommands
Variablesset, incr, unset, append, array variables (a(k)), global, variable, upvar #0
Outputputs, with -nonewline and an optional channel
Expressionsexpr
Control flowif / elseif / else — with the else keyword optional, so if {$x} {a} {b} is the form it is in tclsh — while, for, foreach, switch (-exact, -glob, -nocase), break, continue
Proceduresproc, return (with -codeok, error, return, break, continue or any integer — and -level), apply
Errorscatch (with a result variable and an options variable), error, throw; Tcl return codes across every boundary — a break or continue out of an eval, uplevel or source script reaches the loop, and one out of a procedure reaches its caller
Coroutinescoroutine, yield, yieldto, info coroutine
Namespacesnamespaceeval, current, qualifiers, tail, parent, children, exists, delete, code, inscope, export, import, forget, origin, which, ensemble exists / create / configure; variable; rename
The event loopafterms, ms script, idle script, cancel, info; update, update idletasks; vwait
Scopeuplevel, upvar, apply
Introspectioninfoargs, body, commands, complete, coroutine, default, exists, functions, globals, hostname, level, library, locals, nameofexecutable, patchlevel, procs, script, sharedlibextension, tclversion, vars
Packagespackagefiles, forget, ifneeded, names, prefer, present, provide, require, unknown, vcompare, versions, vsatisfies
Run-time evaluationeval, subst, source, tcl_findLibrary
Listslist, llength, lindex, lappend, lrange, lreverse, linsert, lreplace, lsearch, lsort, join, split, concat
Associative dataarrayexists, get, names (-exact, -glob, -regexp), set, size, unset; dictappend, create, exists, filter (key, value and script), for, get, getdef, getwithdefault, incr, keys, lappend, map, merge, remove, replace, set, size, unset, update, values, with
Regular expressionsregexp, regsub — with -nocase, -all, -inline, -indices, -line, -lineanchor, -linestop, -expanded, -start, regsub -command and --; switch -regexp (with -matchvar and -indexvar), lsearch -regexp and array names -regexp take one too
Stringsformat, scan, and the string ensemble — cat, compare, equal, first, last, index, insert, is, length, map, match, range, repeat, replace, reverse, tolower, totitle, toupper, trim, trimleft, trimright, wordend, wordstart
Channelsopen, close, gets, read, flush, eof, seek, tell, fconfigure, and puts to a channel; stdin, stdout and stderr
Math functionsThe whole of mathfunc(n) inside expr: abs, acos, asin, atan, atan2, bool, ceil, cos, cosh, double, entier, exp, floor, fmod, hypot, int, isfinite, isinf, isnan, isnormal, isqrt, issubnormal, isunordered, log, log10, max, min, pow, rand, round, sin, sinh, sqrt, srand, tan, tanh, wide
Timeclockseconds, milliseconds, microseconds, clicks, format, scan (with -format), add; -gmt, -timezone (a numeric offset or any zone with a TZif file) and the root locale
Encodingsencodingconvertfrom, convertto (with -profile tcl8 / strict / replace and -failindex), dirs, names, profiles, system, user; see Encodings for which
Binary databinaryformat and scan over every field type (a, A, C, b, B, h, H, c, s, S, t, i, I, n, w, W, m, f, r, R, d, q, Q, x, X, @) with the u flag and * counts; encode and decode for base64, hex and uuencode, with -maxlen, -wrapchar and -strict
Filesystemfileatime, copy, delete, dirname, executable, exists, extension, home, isdirectory, isfile, join, mkdir, mtime, nativename, normalize, owned, pathtype, readable, readlink, rename, rootname, separator, size, split, tail, tildeexpand, type, writable; glob with -directory, -join, -nocomplain, -path, -tails and -types; pwd; cd

Command substitution works on any of them.

package is ported from generic/tclPkg.c, version arithmetic included: TIP 268's normalisation and comparison, so 9.0 and 9.0.0 are the same version, 010 and 10 are the same number, 1.2a3 sorts below 1.2, and a component wider than 64 bits still compares correctly. tests/package_differential.rs runs a hundred cases through tclsh and through this binary and requires the completion code and the result string to agree on every one.

Two answers differ from a freshly started tclsh, and both are init.tcl's doing rather than the command's: package names starts empty, because this frontend provides nothing about itself, and package unknown starts unset, because there is no auto_path to search. package files always answers the empty string for the same reason — nothing records which file provided a package, since there is no package index to record.

docs/reference.html is the same surface as a page, generated rather than written: cargo run --bin gen-docs renders every command, ensemble subcommand, operator, operand shape, string is class and format conversion as its own entry — an anchored heading, the signature the compiler reports, and a description — from the corpora in src/names.rs, each of which a test pins to the table it documents. Whether a name is implemented is not written down anywhere: the generator runs each ensemble subcommand and each format conversion and reads the answer. Running rather than compiling is what makes that honest, because a refusal here is lowered as code that raises when reached, so a compile-time probe finds every subcommand acceptable. A command the page lists exists; one it does not is invalid command name.

expr

The whole operator set of expr(n):

GroupOperators
Arithmetic+ - * / % **, unary + - — with Tcl's floored integer division and remainder, and integral ** for integral operands, a negative exponent included (2 ** -1 is 0; a zero base there is an error)
Comparison< > <= >= == != — numeric-preferring, falling back to string order
String comparisonlt gt le ge eq ne — always string
Bitwise / shift& ^ | ~ << >>
Logical&& || !, short-circuiting; the ternary ?:
Membershipin ni — string equality against a list's elements, so 1 in {01} is false

Operands are literals, variables, nested commands ([…]), quoted and braced strings, and parenthesised subexpressions. Doubles print in Tcl's format: the shortest representation that reads back exactly, never looking like an integer, exponential outside the positional range. A literal, though, prints as the script wrote it — Tcl's first rule — so puts 3.0 is 3.0 and puts 007.0 is 007.0.

An expr result keeps the shape the VM computed — an integer, a double, a boolean — and Tcl's string form is applied at the point a string is asked for rather than to the result itself. That is invisible to a script (puts [expr {1.0 + 1}] is 2.0, puts [expr {1 < 2}] is 1) and it is what lets an arithmetic loop compile: an op that converted every result would be an extension op, and one of those in a loop is what fusevm's JIT and its ahead-of-time compiler both stop at.

The always-string comparisons compare the operands as written: expr {1.0 eq 1} is false, expr {010 eq 10} is false, and expr {1e3 eq 1000.0} is false, because a numeric literal carries the text the script gave it as well as its value. == on the same pairs is true — that is the difference the two families of operators exist for.

Where a value is used as a conditionif, while, for, the ternary, &&, || — it has to be a boolean, and Tcl's boolean is narrower than "not empty": a number in any radix, or one of true / false / yes / no / on / off in any case, abbreviated to any prefix that stays unambiguous. t, fals, y, n and of are booleans; o is not, because on and off both start with it. Anything else is expected boolean value but got "…", which is why if {"b"} {…} is an error rather than a taken branch. ! is the exception: it takes a number or a boolean word, and refuses the operand otherwise.

Lists

A Tcl list is a string, so every list command re-derives its elements from one. Both directions are ports of the reference implementation rather than reconstructions from the manual, because neither is what a reading of the manual would suggest.

PieceHow
ParsingTclFindElement: whitespace separates elements, a leading brace or quote delimits one, and backslash sequences resolve everywhere except inside braces — the same escape table as rule 9, reached through the same code.
FormattingTclScanElement / TclConvertElement, including the historical mode where an element needing protection only because of a ] or an internal " has those escaped while its braces are left bare: list {a]b} is a\]b, not {a]b}. An empty element is {}, and a leading # is quoted in the first element only.
Indicesend, end±n, m±n and the integer grammar (0x / 0o / 0b / 0d prefixes, _ separators), resolved as Tcl_GetIntForIndex resolves them.
lsortThe reference merge sort, element for element — with -unique the algorithm rather than the ordering decides which of two equal elements survives, so a library sort would give a different answer. -command calls back into the interpreter once per compared pair and skips every later pair once one has failed, which is how SortCompare keeps the first failure's message.
lsearchThe reference option parsing, including unique-prefix abbreviation and the rule that -integer / -real only apply in -exact mode.
foreachAny number of variable lists and value lists; the longest list fixes the iteration count and shorter ones supply empty values. The loop state rides the VM stack rather than a variable a script could reach, and is read in place, so no copy happens per iteration.
lappendThe op reaches the variable itself rather than taking its value through GetVar, so the list's string is unshared while it runs and the new elements are appended to it — growing a list is linear, not quadratic. What makes that safe without re-deriving the elements is identity: the value the last lappend produced is remembered, and a string that is that value is known to be canonical without a scan. A list another variable holds is copied instead, since the string it shares must not change under it.
lindex and llengthThe other half of that problem: reading a list by index re-derived every element per command, so a loop walking one was quadratic in its length. The elements of the last few lists split are kept, keyed on the value's identity, so a loop over one list parses it once. Reading a list by index at 8,000 elements went from 9.538 s of CPU to 0.066 s and became linear; tests/list_differential.rs pins that a list changed between two reads answers with what it now holds.

Growing a variable

append x … reaches its variable itself — the compiler pushes where the variable lives, not its value — so the op takes the string out of it, finds it unshared, and appends to it. Nothing is copied per append, which makes building a string linear rather than quadratic.

set x "$x…" is lowered as the same op, because that is what it is: an assignment whose word begins with the variable it assigns to only grows it. The rewrite applies when everything after that first $x is text or another variable's value, and not when a command substitution follows it — append reads its variable after its arguments run, a word reads $x before the parts after it, and set x "$x[set x y]" is where those two disagree.

A value another variable holds is copied rather than extended, so a script never sees a string change under it. lappend works the same way, with one more question to answer first — see Lists.

Procedures

A procedure's parameters and locals are frame slots, not entries in the global table: proc collects every signature before anything is emitted, so a procedure may call one the script defines further down, and a call site pushes one value per formal — filling in defaults and collecting a trailing args there rather than in the body. global moves a named variable back to the global table for the body that declared it.

A proc that is not at the script's top level — inside an if, a loop, a command substitution, or another procedure's body — is compiled the same way and bound differently. Its body is lowered where it stands, behind a jump, with the same prologue and the same slots; what changes is that the name is bound when the proc command runs, by an extension op, rather than while the script is compiled. So if {0} {proc f {} {}} leaves f an invalid command name, a definition inside a taken branch replaces whatever the name meant before it, and a procedure that defines another defines it for good once it has run — which is what tclsh does, and what tests/proc_differential.rs compares against tclsh line by line.

Calls follow the definition. A name the compiler can resolve keeps its direct Op::Call; a name some conditional proc defines resolves in a run-time command table instead, at every call site in the script — including the ones written above the definition, and including a name a top-level proc also claims, because only run time knows which definition ran last. The compiler learns which names those are in its first pass and lowers their call sites in the second, so a script with no conditional proc compiles in one pass and its call sites are byte-identical to what they were. bench/counted_loop_proc.tcl is unchanged by all of it: one Op::Call, traced=true.

A procedure is callable from any chunk of the interpreter. source, eval, an after script and a Tk binding script are each a chunk of their own, and an entry point is an op index that means nothing outside the chunk it came from — so every proc binds its name in the interpreter's run-time table as well as in its own chunk's address book, and the table holds the chunk with the entry point. A call that finds a procedure of the running chunk jumps to it; one that finds a procedure of another chunk runs that chunk's body on a VM of its own, positioned the way a coroutine's is, against the same interpreter variables. Four ops per definition, run once where the definition stands; nothing on a call path pays for it. rename moves the table's entry with the registry's, so a name taken away stops answering in every chunk.

{*} argument expansion

A word written {*}$list supplies a number of arguments, which the script decides while it runs. Every other command in this frontend has its callee and its argument count settled while the script is read — the count is an inline operand of the op the call lowers to — so a command containing a {*} is lowered whole instead: the line, then one flag and one value per word, then ext::EXPAND_CALL, which splices the flagged words by list rules and calls what the result spells. That covers the name as well, since {*}{n x} y calls n.

Three kinds of callee, in the order tclsh resolves them: a procedure of the interpreter, entered exactly as any run-time call enters one; a command this frontend compiles, which is rebuilt as a list and evaluated — a list evaluated as a script is one command whose words are its elements, with no substitution left to do, which is why set {*}{a b} assigns and if {*}{1 {puts yes}} runs its body; and anything else, which is a command Tk registered or an invalid command name. A command whose words all expand to nothing runs nothing and answers the empty string, as tclsh does.

Only a command that has a {*} pays anything: one LoadInt per word of that command, and the op instead of the call. tests/expand_differential.rs compares 41 programs against tclsh byte for byte.

Coroutines

A coroutine is a second fusevm::VM over the same chunk. coroutine name cmd ?arg…? positions it at the procedure's entry and enters it; yield halts that VM and hands its value to whoever resumed it; resuming pushes a value and runs it again. yieldto donates the resumer to another coroutine of the script, so the value of a call that ends in yieldto is whatever the target eventually produces. A body may suspend at any depth, inside a loop, and inside an open catch; an error that escapes a body deletes the coroutine and is reported to whatever resumed it; a coroutine's command goes away when its body ends, so a later call reports invalid command name.

The driver owns the one global variable table and moves it into whichever VM is about to run, so every context sees the same variables. Exactly one VM runs at a time, so that is a move and not a copy.

eval

eval is the one command whose script is a value rather than braced text, so it is compiled when the op runs. The chunk cache is keyed by the source text — identical source is identical bytecode, whatever produced it — so eval in a loop is lowered once however many times it runs. The nested script sees the interpreter's variables in both directions, including the ones a failing nested script had already set.

Inside a procedure body it sees that procedure's frame, which is what tclsh does: a local is readable and writable, a variable the script creates becomes a local, and a global the body did not declare is refused there exactly as it is in the body. A procedure's locals are frame slots, so nothing addresses them by name once the chunk is built — proc therefore records the name of each slot in the chunk (fusevm::Chunk::sub_slot_names), and the op runs the script against a projection of the frame built from them, reading it back into the slots afterwards. Every activation is projected, including one whose body happens to declare no local at all.

A ::-qualified name is the exception the projection is built around: $g in such a script is the frame's local and $::g is the interpreter's variable, so the qualified spelling reaches past the frame in both directions — a read answers from the interpreter and a write lands there. The two are told apart by the spelling the chunk keeps, which is a separate name only in a script lowered to run in a projection; everywhere else ::g and g are one variable and share one name. info locals asked inside such a script answers with that frame's names, which is a run-time fact rather than anything the lowering can know: the script is a chunk of its own, compiled at the script's own level.

uplevel ?level? arg … is the same mechanism aimed at a different frame: #0 is the global level, a bare number counts calls outwards from the running one, and a level that does not exist is bad level "…". Only a procedure call is a level — the frame a scope or a JIT side exit pushes is not one — so uplevel 1 at a script's top level reports bad level "1" as it does in tclsh. Control flow does not cross one: break, continue and return belong to the script uplevel is running, which is measured against tclsh in tests/frame_differential.rs.

apply {params body ?ns?} ?arg …? runs a lambda as what it is — a procedure body with a frame of its own, its own locals, the same defaults and variadic args rules, and return returning from it. A wrong argument count is reported against apply lambdaExpr, because a lambda has no name to report. The lambda itself may be computed: the op writes it out as a proc under a name no Tcl name can be, runs that, and renames the synthesised name out of any diagnostic — so set f {{x} {expr {$x+1}}}; apply $f 1 works, and works through the same frame machinery a written-out lambda does.

upvar ?level? otherVar localVar … is the third way into another frame, and the only one that is not a script: an alias, which every later command of the body reads and writes through. The level and the target may both be computed, and the target may be an array element. It resolves to one of three homes — a global at its index in the chunk's projection, a global whose computed name the chunk's table does not carry, or a frame slot by frame and slot index.

A name the procedure running at that level never wrote has no slot the compiler could have assigned it, so the frame grows one when the link asks for it, and the name is a local of that activation like any other: a script in that frame reads and writes it, info locals lists it, and it dies with the call rather than becoming a global that outlives it. The same run-time slot serves the other three ways a name arrives after a body is compiled — eval {set qq 9} in a body that never writes qq, uplevel 1 {set made 1} into a caller that never writes made, and a dict with key the body never spells. It costs that one activation its compiled trace, which is the cost a procedure-local array already carries.

Namespaces

A namespace is resolved where everything else in this frontend is resolved: while compiling. namespace eval foo { … } lowers its body into the enclosing chunk with the compiler's current namespace switched, and that one switch is what every name in the body is resolved against.

Written inside ::fooReaches
set v 1the interpreter variable foo::v
proc p {…} {…}a procedure registered as foo::p
p::foo::p if there is one, otherwise ::pTclGetNamespaceForQualName's two-step search
variable v inside a procedurelinks the local name v to ::foo::v
global v inside a procedurethe root namespace's v, even when ::foo has one

The root namespace is the empty prefix, so a global variable keeps the name it always had and a script that uses no namespace compiles to exactly the bytecode it compiled to before. The queries — namespace exists, children, which, origin, parent — read a registry the interpreter holds and the compiled code fills in as it runs, so they answer for what the script actually created.

Because the resolution happens while compiling, a namespace this compiler cannot read is refused rather than guessed at: namespace eval $n {…}, a computed body, and namespace path / unknown / upvar, which would change a resolution after it was made.

source and tcl_findLibrary

source reads a file and evaluates it through the same path eval takes, so it shares the interpreter's variables in both directions — including namespace variables, which are interpreter variables under their qualified names. Its procedures are its own chunk's, and do not survive it; BUGS.md has the entry.

tcl_findLibrary is not a C command in Tcl either — it is a procedure of Tcl's own library, library/auto.tcl, and src/cmd_source.rs is a port of it. It walks the same directories in the same order: the package's environment variable, then $auto_path with the macOS Resources/Scripts case under each, then three directories relative to the executable; the first that holds the initialisation script sets the library variable and is sourced. tclrs::cmd_source::seed_library_environment sets the tcl_library, tcl_libPath and auto_path that Tcl's own init.tcl sets from C state this crate has no equivalent of.

Encodings

encoding convertfrom and encoding convertto are table lookups, and the tables are the specification: a mapping one code point out produces output that looks exactly as plausible as the right output. So none of them is typed into this crate. Every table is a byte-for-byte copy of a library/encoding/*.enc file from the Tcl source release that conformance/fetch-suite.sh verifies against a pinned SHA-256, vendored into src/encodings/ by scripts/gen_encoding_tables.py, and src/cmd_encoding.rs reads them with a port of the reader they were written for — LoadTableEncoding, generic/tclEncoding.c. diff -r src/encodings conformance/vendor/tcl*/library/encoding is the whole provenance check. There is no new dependency: the data is data, and the conversion is a port of TableToUtfProc and TableFromUtfProc.

That covers every table encoding the release ships — the iso8859-* family, the cp* code pages, the mac* set, koi8-*, ascii, ebcdic, the symbol/dingbats fonts, and the double- and multi-byte CJK encodings (big5, cp932, cp936, cp949, cp950, euc-cn, euc-jp, euc-kr, gb2312, gb2312-raw, gb12345, jis0208, jis0212, ksc5601, macJapan, shiftjis, cns11643), including the prefix-byte machinery a double-byte encoding decodes through and the trailing reverse-mapping section four of the Japanese tables carry. The rest — utf-8, cesu-8, utf-16/utf-16le/ utf-16be/unicode, ucs-2/ucs-2le/ucs-2be and utf-32/utf-32le/utf-32be — are ports of the corresponding procs.

encoding names answers exactly the set that converts, sorted. It is not tclsh's list: tclsh answers in its hash table's order and includes the three escape-sequence encodings, which are state machines rather than tables and are refused here by name. A script can therefore trust the list — what it offers, it converts.

The profiles are tcl8, strict and replace, with strict the default, and they are ported rather than approximated, down to the parts the manual page gets wrong. Two examples, both measured against tclsh 9.0.4:

  • encoding(n) says the tcl8 profile maps an invalid byte to "its numerically equivalent code point" outside utf-8, and gives encoding convertfrom -profile tcl8 ascii A\x80 as U+0041 U+0080. It is U+0041 U+20AC: tclsh reads a stray byte as the cp1252 character of that number where cp1252 defines one, for the table encodings as much as for utf-8.
  • the same page states that strict is the default and then shows encoding convertto iso8859-1 A\u0141 answering A?, which is what tcl8 does. With no -profile it raises unexpected character at index 1: 'U+000141'.

-failindex and the error message do not report the same number for convertto: the variable gets a byte offset into the string's UTF-8 form and the message gets a character index. For éé€ in iso8859-1 they are 4 and 2. Both are reproduced.

fconfigure -encoding takes the same set, through the same tables, and a character split across two reads is still one character — the channel holds an incomplete sequence until the rest arrives, which is what TCL_CONVERT_MULTIBYTE means. A channel's profile is strict, as tclsh's is, so a byte sequence it cannot decode or a character it cannot encode is error reading "fileN": invalid or incomplete multibyte or wide character rather than a substitution.

Two answers differ deliberately. encoding dirs starts empty, because the tables are inside the binary and there is no directory to search — tclsh's initial value is where its own library was installed. And identity and binary are not encodings: measured, tclsh 9.0.4 answers unknown encoding "identity" for both, so this frontend does too rather than reviving a Tcl 8 spelling.

Binary data

A byte string in Tcl 9 is a string whose every character is below U+0100, and that is what binary produces and consumes here — binary format c 200 is the one character U+00C8, exactly as in tclsh. src/cmd_binary.rs is a port of generic/tclBinary.c rather than a reading of binary(n), because four of its rules are not in the manual page and each one is observable:

  • A field specifier is a type character, an optional u flag and an optional count, in that order, with leading blanks skipped. The bad field specifier message names the character the format pointer was on before that skip, which is why binary format {c 3} 1 2 reports a blank rather than the 3 that actually stopped it.
  • binary format runs two passes. The first resolves every count, checks that an argument exists for each field that consumes one, and computes the length; only the second looks at a value. So binary format b3c x is not enough arguments for all format specifiers and not a complaint about x.
  • x writes null bytes rather than skipping over what is already there, and the result's length is a high-water mark rather than the cursor — both of which only show once X or @ has moved the cursor back. binary format {su1X8s0x3} 1 255 is three null bytes.
  • A field with no count takes its argument whole and a field with a count of one takes the argument's first element, so binary format c {2 5} is expected integer but got a list while binary format c1 {2 5} is one byte.

The integer fields truncate modulo the field's width at the precision Tcl 9's integers actually have: binary format c 99999999999999999999 is that number's low byte, not a refusal and not a saturated i64.

tests/binary_differential.rs compares the whole surface against tclsh, and every_field_type_round_trips_through_tclsh drives every field type through format and back through scan at every count form and both flag settings as one generated program.


[0x05] WHAT IS REFUSED

Nothing is approximated. A construct this frontend has not built is an error, at compile time where the script's shape decides it and at run time where a value does. BUGS.md is the ledger.

RefusedMessage
Any command outside the list aboveinvalid command name "X"
An expr math function a script defines under tcl::mathfunc::invalid command name "tcl::mathfunc::triple"
clock scan without -format, and clock's -locale outside the root catalogueclock scan: the free-form parser is not supported yet; use -format
A clock instant before the Gregorian changeover, where the calendar depends on the localeclock: dates before the Gregorian changeover of 1752-09-14 are not supported yet
file attributes, link, stat, lstat, channels, system, tempfile, tempdir, volumesfile stat is not supported yet: it needs an interface this frontend has not built
An ensemble subcommand that is not literal (string $sub x, info $sub v, array $sub a)subcommand must be a literal in this phase
A body word that is not literal (while $cond $body), a foreach / lmap / lassign variable list, dict update's variable names, and the array name of array exists / names / size / get / set / unsetthe word is refused where a literal is required
An array variable in a foreach variable listarray variables are not supported yet
array startsearch and the other search subcommandsarray startsearch is not supported yet
dict info, which reports the hash-table statistics of the object rather than of the value — two dictionaries with the same string answer differently when one of them shrank, and a third answer again once a list holds one, so it needs a dict that retains its table and a count of what holds it, see BUGS.md; dict set, dict incr, dict update or dict with into an array element; dict update's variable names when they are not literaldict info is not supported yet
string wordend / wordstart past ASCIIstring wordend/wordstart: characters beyond ASCII need Unicode category tables, which are not built yet
format %a / %A; any other letter is bad field specifier "n" instead. These are the one conversion Tcl does not perform: it builds the C spec and calls the platform snprintf (generic/tclStringObj.c:2547), so the answer is the C library's and the libraries this crate builds against do not agree — see BUGS.mdthe "%a" conversion is not supported: tclsh hands it to the platform C library …
regexp -about. The group count is easy; the flag list is the reference engine's own compile-time telemetry (REG_UUNPORT, REG_UNONPOSIX, …), which a different engine can only guess at — and the result is one list, so half of it right and half of it guessed is a wrong listregexp -about is not supported yet: its second element is the reference engine's own compile-time telemetry …
Redefining a built-in — including from a proc away from the top level; redefining a procedure at the top level; a procedure and a coroutine of the same name. A proc away from the top level is not refused: it binds its name when it runsredefining the built-in command "set" is not supported
return -errorcode, -errorinfo and -options. -code and -level are implemented, and so is catch's options variable — which carries those two and not tclsh's -errorstack / -errorcode / -errorinfo / -errorlinereturn option "-errorinfo" is not supported
yield or yieldto inside a script run by eval, uplevel or apply. tclsh suspends the coroutine from inside the nested script; here that script runs a machine of its own, below the VM that would have to park, and that VM saves only its own state — so resuming could not return to the middle of the scriptyield inside a script run by "eval", "uplevel" or "apply" is not supported: a coroutine cannot suspend across one
coroutine anywhere but a script's top level or a command substitution in one; a coroutine of a built-in or of anything but one of the script's procedures; yieldto at a command that is not a coroutine of the script"coroutine" is only supported at the top level of a script, or in a command substitution in one
A computed namespace eval name or body, or a computed namespace import patterna computed "namespace eval" name is not supported yet: this frontend resolves namespaces while compiling, so the name has to be written out
namespace path, namespace unknown, namespace upvar"namespace path" is not supported yet: this frontend resolves namespaces while compiling, so the name has to be written out
Calling a command namespace ensemble create made; variable naming a qualified name inside a procedurebad variable name "a::b": can't create a local variable with a namespace separator
source -encoding for anything but UTF-8"source -encoding" is only supported for utf-8: this frontend reads a script as UTF-8
namespace eval inside a procedure body, where an unqualified name in its body would take a frame slot rather than the namespace's variable"namespace eval" inside a procedure is not supported yet: an unqualified name in its body would take a frame slot rather than the namespace's variable
info subcommands that need machinery this frontend has none of: frame, errorstack, cmdcount, cmdtype, class, object, consts, constant, loaded; and info level N, which needs a record of the command that entered a levelinfo frame is not supported yet
info library — a raise rather than a refusal, carrying tclsh's own message for an interpreter with no script library, which this one permanently isno library has been specified for Tcl
A return inside a script eval, uplevel or apply runs, where the script is a chunk of its own and that chunk is no procedure"return" outside of a procedure is not supported
A lambda naming a namespace other than ::, written out or computedthe namespace "::ns" of a lambda is not supported yet: this frontend has only "::"
vwait on more than one variable, and its -timeout / -readable / -writable / -all options"vwait" takes at most one variable name in this phase
open |command — the pipeline form — and the POSIX list form of an access mode ({WRONLY CREAT})opening a command pipeline is not implemented in this frontend; …
A channel encoding encoding names does not list; fconfigure -blocking 0; fconfigure -eofchar and -profile when set; half-closing a read-write channelunknown encoding "iso2022-jp"
The escape-sequence encodings iso2022, iso2022-jp and iso2022-kr. These are state machines with a file format of their own, not tables, and they are absent from encoding names so a script can see that before it convertsencoding: the escape-sequence encoding "iso2022-jp" is not supported yet; …
A decode whose result would be an unpaired surrogate, which only -profile tcl8 produces. tclsh's strings can hold one and this frontend's cannot, so the code point is named rather than substitutedencoding convertfrom: the tcl8 profile decodes this input to the lone surrogate U+D800, which a string in this frontend cannot hold
A non-literal option name in encoding convertfrom / convertto (encoding convertfrom $opt tcl8 …). Which argument is an option is decided by their count, which is known while compiling; which option it is, is notthe word is refused where a literal is required
Input nesting past parser::MAX_NESTING_DEPTH — 64_000 command substitutions or array indices deep, well past anything the reference interpreter survivestoo many nested substitutions (infinite loop?)
Ahead-of-time compilation of a script using catch or a coroutineahead-of-time compilation of a script using "catch" is not supported: it needs the driver that only the interpreter has

coroprobe, coroinject and deleting a coroutine by renaming its command are not implemented; a coroutine goes away when its body ends.


[0x06] THE PARSER

tclrs::parse implements all twelve syntax rules of Tcl(n):

RuleCovered by
1 Commands, 3 Wordscommand and word splitting, line tracking
2 Evaluationwords retained in order for the compiler
4 Double quotesquoted words with substitution
5 Argument expansion{*} recorded on the word, spliced by ext::EXPAND_CALL when the command runs
6 Bracesnesting, literal text, backslash retention
7 Command substitutionnested scripts parsed eagerly
8 Variable substitution$name, $name(index), ${name}, ${name(index)}
9 Backslash substitutionfull escape table, including the backslash-newline pre-pass
10 Comments# in first-word position only
11, 12 Order and word boundariessingle pass, substitution never splits a word

Rule 11 rules out rescanning substituted values, so each character is processed once and the compiler can resolve variable and command references statically wherever the word shape allows.

Braces nest through a counter, so a script of a million { costs no stack, but command substitution and an array index are recursive — a [ inside a [ is a nested script. That recursion is bounded by parser::MAX_NESTING_DEPTH, because running out of native stack is a signal with nothing to report rather than an error a script can be blamed for. The limit is 64_000 and it is measured: on the stack the binary gives the parser (runtime::RECOMMENDED_STACK) a script of nothing but [ still parses at 80_000 levels and aborts by 90_000, and tclsh segfaults on the same input between 20_000 and 30_000 — so the bound sits above every depth the reference interpreter itself survives, and refuses nothing tclsh can parse.


[0x07] ARCHITECTURE

tclrs contains no virtual machine, no interpreter loop, and no code generator. The execution path mirrors how zshrs hosts zsh and groovyrs hosts Groovy:

Tcl script → parser (Script/Command/Word) → fusevm bytecode → Interp → Machine → fusevm VM

                                                     numeric hook (string operands, overflow, exact integer-vs-double order)
                                                     extension ops (/ % ** floored, puts, string compare, …)
                                                     enable_tracing_jit
PieceHow
fusevm-hostedNo local vm.rs / jit.rs. Each command lowers into a fusevm::Chunk and runs on the shared VM.
InterpThe variables of a session, keyed by name, plus the source-keyed chunk cache. A chunk interns its own name table, so a slot vector cannot cross evaluations; the map is the authority and the vector is projected out of it on entry and read back into it on exit.
MachineOne evaluation. It switches coroutine contexts, unwinds catch, services the requests coroutine ops raise, and moves the global slot vector between the VMs of one chunk. Every one of those works the same way: an op stashes something in a cell and halts, and the driver reads the cell after run() returns.
One install pointThe output sink, the numeric hook, the extension dispatch and enable_tracing_jit are installed in exactly one function, so the main VM, a coroutine's VM, a nested eval's VM and an ahead-of-time run all behave alike.
Numeric hookCatches operands the VM cannot compute on natively. An operand that parses as a number is one (including the 0x / 0o / 0b / 0d radix prefixes and _ as numeric whitespace); comparisons fall back to string order when it does not; arithmetic on a non-number is an error. An integer past i64 is where the hook earns its keep: fusevm's checked arithmetic hands the operands over on overflow, the hook computes the exact answer as a BigInt and returns it as its canonical decimal, and the fast path stays i64 in registers. The hook also owns one comparison the VM could answer itself: an integer past 2532^{53} against a double. Reading the integer as an f64 lands on a neighbouring value, so expr {3**34 == double(3**34)} would be 1 where tclsh says 0 — Tcl orders an integer against a double exactly, at every width, even though its arithmetic on the same pair promotes to a double. Only the frontend knows which of the two rules its language wants, so fusevm asks.
Extension ops/ and % floor toward negative infinity (-57 / 10 is -6, -57 % 10 is 3), ** stays integral for integral operands including a negative exponent (2 ** -1 is 0), and a boolean op applies Tcl's rule for a condition, which is not the VM's truthiness. Tcl's string form is a frontend op wherever one is needed — puts, the always-string comparisons, word concatenation — because the VM's own stringification is not Tcl's for a double or a boolean, and none of those ops is JIT-eligible in fusevm anyway, so owning them costs no tier. An expr result is not converted: it stays the value the VM computed, which is what keeps an arithmetic loop free of extension ops. The list, associative and string commands are extension ops too.
No object heapTcl's value model needs none on top of fusevm's: strings, integers and floats map onto Value directly.

Extension op ids are laid out so runtime's dispatch can test ranges from the highest base down: the arithmetic ops and puts at 0–5, eval at 6, control flow at 7–9, the coroutine ops at 10–14, the boolean conversion at 15, the list commands from 16, the associative ones from 64, the string ones from 128, the regular-expression ones from 192 and the channel commands from 256. catch is the one op whose payload is an op index, so it is an extension-wide op. The channel ops are the one family dispatched from the hook closure rather than from runtime::extension, because they need the running interpreter's output sink: puts stdout has to reach wherever puts reaches, including a capture.

Static stack tracking is what keeps the lowering cheap: each command leaves its result on the stack and the compiler tracks that depth as it goes, so break and continue unwind with a known number of pops rather than a runtime unwinder. Every loop — while, for, foreach, dict for — is emitted by one function, Compiler::rotated_loop, which is what keeps that arithmetic and the rotated branch layout the tracing JIT needs in a single place rather than repeated four times.

Tcl has one shape that is neither: a cleanup that runs however the body ended. dict update and dict with write their variables back into the dictionary after an error, a break and a return alike, because the reference implementation evaluates the body with the write-back already pushed as an NRE callback (FinalizeDictUpdate, generic/tclDictObj.c:3539; FinalizeDictWith, :3696). There is no NRE stack here, so Compiler::finally_region builds the same shape out of the catch region: the region absorbs every code so the cleanup runs, and then hands the code back on unchanged. One op, ext::RERAISE, is the whole of the difference between a catch and a finally.

dict with needs one thing beyond that region, and it is the only command here that does: its variables are named by the dictionary's own keys, which are values rather than words of the script. Each key is resolved to a home when the command runs — the resolution a computed upvar target gets — so at a script's own level it is a global, interned past the chunk's name table when the table does not carry it, and inside a procedure it is a frame slot. A key written a(i) names one element of an array, as Tcl_ObjSetVar2 makes of it. A key whose name a procedure body never spells has no slot, and rather than refuse a record because the body ignores one of its fields, the command carries that key's value in its own write-back record; BUGS.md has the one case that does not cover.


[0x08] JIT COMPILATION

How it is turned on

fusevm is pulled with the Cranelift features, so cargo build links the JIT and the persistent native-code cache:

fusevm = { version = "0.26.0", features = ["jit", "jit-disk-cache", "aot", "ffi"] }
FeatureWhat it adds
jitfusevm's Cranelift tiers — linear, block, tracing.
jit-disk-cacheCompiled native code persists to ~/.cache/fusevm-jit, so codegen is not repaid on the next process. Relocate it with FUSEVM_JIT_CACHE_DIR, disable it with FUSEVM_JIT_CACHE_DIR=off.
aotThe closed-world compiler behind --aot.
ffiThe compile-and-dlopen path behind rust { ... }.

One call arms the tiers, in the same function that installs every other hook, so the interpreter, the binary, a coroutine's VM and an ahead-of-time run all get the same VM.

What the tiers reach on Tcl today

Not an estimate. tclrs --tiers asks fusevm's own predicates (is_block_eligible, is_trace_eligible, trace_is_compiled, block_jit_is_compiled) after running the script. Every counted loop this frontend emits now reaches a compiled trace, whether its counter is a procedure's local or a script's top-level variable; what a loop still fails on is an extension op in its body.

A loop inside a procedure reaches a compiled trace

proc count {n} {
    set i 0
    while {$i < $n} {
        incr i
    }
    return $i
}
puts [count 3000000]
$ tclrs --tiers bench/counted_loop_proc.tcl
ops                     28
block-JIT eligible      false
block-JIT compiled      false
largest eligible region none
loop @7                trace-eligible=true traced=true blacklisted=false
block-ineligible ops
  Call                  1
  Extended              1
  ReturnValue           2
reaches native code     true

The ops listed are the ones the block tier refuses, which is a different question from whether a loop is traced: the block tier compiles a chunk whole or not at all, and the Call around this loop settles that. It is the tracing tier that runs here.

traced=true. Two things have to hold at once for that, and each was a separate blocker.

The ops. A procedure's locals are frame slots, so the counter is GetSlot / SetSlot, which the tiers have always accepted. A top-level Tcl variable is a VM global instead, which the tracing tier used to refuse and now takes — see below.

The shape. fusevm arms its trace recorder at a backward branch and closes the recording when a branch lands back on the anchor. A textbook while — evaluate the test, JumpIfFalse forward past the body, close with an unconditional backward Jump — records an op sequence that is_trace_eligible accepts and that the trace compiler then declines, so the recording is aborted and nothing is ever installed. A do-while, whose conditional backward branch closes the loop, compiles. Every loop this frontend emits is therefore rotated into that shape (Compiler::rotated_loop, src/compiler.rs):

    Jump -> cond          ; enter at the test, so it still runs before iteration 1
  body:
    <body>
  step:                   ; `for`'s third clause; empty for `while`
    <step>
  cond:
    <cond>
    JumpIfTrue -> body    ; conditional BACKWARD branch
  end:

while {$i < 300000} {incr i} inside a proc, before and after:

Before — declinedAfter — traced
05 GetSlot(0) ← anchor05 Jump(12)
06 LoadInt(300000)06 GetSlot(0) ← anchor
07 NumLt07 LoadInt(1)
08 JumpIfFalse(16)08 Add
09 GetSlot(0)09 Dup
10 LoadInt(1)10 SetSlot(0)
11 Add11 Pop
12 Dup12 GetSlot(0)
13 SetSlot(0)13 LoadInt(300000)
14 Pop14 NumLt
15 Jump(5)15 JumpIfTrue(6)

Rotation moves where the exits land: break still jumps past the loop, and continue jumps to the step, because in a rotated loop the next test sits below the body. for's step therefore still runs on continue, and a break inside the step still ends the loop, as for(n) specifies. The condition is still evaluated before the first iteration — that is what the entry Jump is for — so while {0} {...} runs its body zero times and a loop's own value is still empty. for / foreach / while programs covering break, continue, zero iterations, a loop's own value, multi-variable foreach, nesting, and a body that leaves values on the stack per iteration are all checked byte for byte against tclsh in tests/execution_differential.rs.

The chunk as a whole stays block-ineligible for a separate reason — the Call and the puts around the loop — so the whole-chunk tier is not what runs here. It is the tracing tier.

A loop at a script's top level reaches one too

$ tclrs --tiers bench/counted_loop.tcl
ops                     19
block-JIT eligible      false
block-JIT compiled      false
largest eligible region none
loop @5                trace-eligible=true traced=true blacklisted=false
block-ineligible ops
  Extended              1
  GetVar                3
  SetVar                2
reaches native code     true

This is the row that used to read trace-eligible=false traced=false. A Tcl variable at a script's top level lowers to a VM global, and fusevm's tiers accepted slots and not globals: Op::GetVar and Op::SetVar were absent from is_block_eligible_op_at, and the tracing tier defers to that same predicate for everything but Call / Return (is_trace_op_allowed_at). Nothing about the loop's arithmetic or shape was the problem — tclrs --disasm shows NumLt, Add, LoadInt, Jump and JumpIfTrue, no extension op anywhere in it.

fusevm 0.15.0 takes them, by the same mechanism it already had for slots: the globals a trace references are promoted to registers when the trace is entered and spilled back at every exit, including every side exit. Two details make that safe for a Tcl script rather than only for a synthetic loop:

  • The entry guard is per referenced index, not per table. The slot path can ask "are all slots numeric?"; the equivalent question about globals is always no, because argv0, argc and argv are strings in every run. Only the indices the trace actually touches are checked, so a script's string variables neither block the trace nor get flattened by the spill.
  • A trace that would write a global that is not numeric at entry is refused outright, because the write-back would otherwise drop the store silently.

The ops are still listed above because that list answers the block tier's question, and the block tier still refuses globals — the whole chunk is not compiled in one piece. The loop inside it is.

Wrapping a hot loop in a proc is no longer the workaround it was; the counted_loop and counted_loop_proc benchmark rows now land within a millisecond of each other.

foreach and dict for reach nothing either, for a third reason

Both are rotated too, and neither is trace-eligible in any spelling — not even with its variables in a procedure's slots:

proc sum {l} {set t 0; foreach x $l {incr t $x}; return $t}
set l {}
set i 0
while {$i < 2000} {lappend l 1; incr i}
puts [sum $l]
$ tclrs --tiers foreach_proc.tcl
ops                     58
block-JIT eligible      false
block-JIT compiled      false
largest eligible region none
loop @10               trace-eligible=false traced=false blacklisted=false
loop @39               trace-eligible=false traced=false blacklisted=false
block-ineligible ops
  Call                  1
  Extended              6
  GetVar                3
  ReturnValue           2
  SetVar                3
reaches native code     false

loop @10 is the foreach inside the procedure — slots, and still refused; loop @39 is the top-level while that builds the list, refused for lappend now that its global counter is no longer a reason. foreach's loop state is carried by four frontend extension ops (FOREACH_INIT / MORE / TAKE / ADVANCE), and is_trace_op_allowed_at rejects Op::Extended outright — an extension handler is arbitrary Rust with no Cranelift lowering. dict for is refused the same way through DICT_PAIRS, plus the two hidden globals its cursor uses. Rotation cannot help either of them; lowering their state to native ops could.

What Tcl's boolean rule costs, and where

That rejection of Op::Extended is why the conversion a Tcl condition needs is emitted selectively. A condition has to be a boolean — if {"b"} is an error, not a taken branch — and the rule is a ported one (ParseBoolean, tclObj.c), so it lives in an extension op. Putting one before every branch would have taken the compiled trace away from every loop in the language.

Compiler::yields_number decides it statically: an expression whose top-level operator answers with a number needs no conversion, because the VM's truthiness and Tcl's agree on every number. A relational or arithmetic test — which is what a counted loop's is — is therefore untouched, and only a condition whose value could be a string pays:

proc h {n} {set i 0; set go 1; while {$go} {incr i; if {$i >= $n} {set go 0}}; return $i}
proc h2 {n} {set i 0; while {$i < $n} {incr i}; return $i}
loopconditionopstrace-eligibletraced
h2$i < $n29truetrue
h$go42falsefalse

Both are proc-local, both are rotated, and the second is refused for the one Extended in its body. That is the whole cost of the rule, it is measured rather than assumed, and the alternative was answering the wrong thing.

The disk cache

jit-disk-cache is enabled and ~/.cache/fusevm-jit is live, so a proc-local loop's compiled trace outlives the process. The saving is below noise on this machine: counted_loop_proc is 6.3 ± 0.3 ms with FUSEVM_JIT_CACHE_DIR=off and 6.7 ± 0.2 ms with the cache on, 5 runs after 2 warmup runs — Cranelift codegen for a ten-op trace is cheap enough that the cache read costs about what it saves. It earns its place on larger traces, not this one.


[0x09] AHEAD-OF-TIME COMPILATION

--aot produces a standalone native executable with no parser and no compiler inside it — the bytecode is baked in, already lowered. Whether the dispatch loop is gone too depends on the script.

tclrs --aot hello hello.tcl              # emit + link
./hello                                  # runs; exit status is the script's
tclrs --aot-object hello.o hello.tcl     # just the relocatable object

The pipeline, all of it fusevm's except the first and last steps:

script → parser → compiler → fusevm::Chunk

              fusevm::aot::compile_object → hello.o
                                 │  exports fusevm_aot_entry (native driver)
                                 │          fusevm_aot_chunk_blob / _len

     cc main.c hello.o libtclrs.a → hello
                                 │  main.c calls fusevm_aot_run_embedded()

     runtime: deserialize chunk → VM → fusevm_aot_register_builtins(vm)
                                     → native driver → exit code

src/aot_runtime.rs is this crate's whole contribution to the linked binary: the fusevm_aot_register_builtins hook fusevm calls back into, which installs the same hooks the interpreter installs. crate-type = ["rlib", "staticlib"] in Cargo.toml is what produces the libtclrs.a it links against; set TCLRS_STATICLIB to point the link somewhere else.

What runs natively, and what does not

fusevm's ahead-of-time compiler lowers scalar arithmetic, comparisons, branches and globals to registers, runs string / list / hash ops through a boxed shim, and turns anything it has no lowering for into a deopt point that hands the rest of the run to the interpreter. Every operation this frontend implements as an extension op is such a point: /, %, **, in / ni, puts, the always-string comparisons, eval, all thirteen list commands, foreach, every array and dict operation, and the whole string ensemble.

expr is deliberately not on that list. Every expr used to end in an op that converted its result to Tcl's string form, so a loop that computed anything deopted on its first iteration and ran interpreted from there; the conversion now happens where a string is actually asked for, and arithmetic lowers to native ops end to end. That is the difference between counted_loop_expr taking 251.5 ms ahead-of-time compiled and taking 5.9.

What AOT removes for a script that does reach a deopt point is the parse and the lowering, not the dispatch loop — a small number, and the benchmarks measure it as such. What it removes for a script with no extension op in its hot path is the dispatch loop as well, and that number is not small: 5.1 ms against tclsh's 399.3 for three million iterations.

Semantics do not change, and that is tested

Every benchmark-shaped program is run both ways and compared byte for byte, including the failing ones. That caught a real divergence: Tcl integers are arbitrary-precision and so are this frontend's, so an i64 overflow promotes through the numeric hook — but native codegen wraps, and AOT printed -9223372036854775808 where the interpreter answered 9223372036854775808. Every chunk now carries int_overflow_deopt, so Add / Sub / Mul stay native registers on the common path and deopt into the hook when a result does not fit. The same flag is why the JIT, armed on every VM, cannot wrap either.

Limitations

  • catch and coroutines are refused. Both are driven from outside VM::run — the driver reads a cell an op parked, restores the VM and runs it again — and fusevm's ahead-of-time entry owns the run and never hands control back mid-way. Compiling one would turn a caught error into a fatal one, so --aot and --aot-object refuse the script instead.
  • One script, one binary. The chunk is baked in at compile time. No argv, no reading a script at run time, no source.
  • Everything the frontend does through an extension op deopts — see above.
  • macOS emits a linker warning: ld: warning: no platform load command found in …tclrs_aot_*.o, assuming: macOS. The object cranelift-object writes carries no platform load command; the link and the binary are fine.
  • No cross-compilation. cranelift_native targets the host.
  • The binary is large — it links the whole runtime, Cranelift included, because libtclrs.a is one archive.

[0x0A] BENCHMARKS

Reproduce from a fresh checkout:

bench/run.sh                            # every script in bench/
RUNS=20 WARMUP=5 bench/run.sh           # what the numbers below were taken with
bench/run.sh bench/counted_loop.tcl

bench/run.sh builds the release binary, compiles each script with --aot, and runs four configurations of every script under hyperfine — falling back to a warmed Time::HiRes loop when hyperfine is not installed. Every row is wall clock of a whole process, including startup, and every row runs through env so none of them pays for an exec the others do not:

RowCommand
tclshenv tclsh SCRIPT
tclrs interpenv TCLRS_JIT=off target/release/tclrs SCRIPT
tclrs JITenv TCLRS_JIT=on target/release/tclrs SCRIPT
tclrs AOTenv target/bench/NAME — built by tclrs --aot

Measured

Apple M5 Max, macOS 26.5.2, rustc 1.97.0, --release (lto = true, codegen-units = 1), tclsh 9.0.4 from /opt/homebrew/bin, fusevm 0.15.0, 20 runs after 5 warmup runs, hyperfine -N — each command exec'd directly.

The integer_arith row predates the shift-distance check. A shift is an extension op wherever it appears and an extension op in a loop body costs that loop its trace, so that row's JIT and AOT columns are now interpreter-speed; BUGS.md records the measurement and the two candidate lowerings that would give it back. The other rows do not shift and are unaffected.

-N matters at this scale. With a shell in the way, hyperfine measures the shell's own startup and subtracts it, and on a loaded machine that correction once came out larger than the command itself: an ahead-of-time row that runs in about 5 ms was reported as 0.0 ms ± 0.0 with a relative of inf ± NaN. Exec'd directly there is nothing to subtract, so every row carries its process spawn and none of them can go negative. The numbers are therefore ~1–2 ms above what an earlier shell-calibrated run reported for the same work.

The machine is a shared workstation and its load average sat near 12, so a row's mean carries whatever else was running; the table is the minimum of the 20 runs, and the means are below it so the spread stays visible.

Minimum of 20 runs, in milliseconds:

Benchmarktclsh 9.0.4tclrs interptclrs JITtclrs AOT
startup — the empty script12.44.54.52.9
counted_loop_proc$ — 3\text{M} \times $incr, inside a proc51.5185.95.64.3
counted_loop$ — 3\text{M} \times $incr, at the top level386.6175.35.84.3
counted_loop_expr$ — 3\text{M} \times $set i [expr {$i + 1}]461.9175.87.04.7
integer_arith$ — 1\text{M} \times $$sum + $i * $i - ($i >> 3)284.7143.66.44.2
string_build$ — 100\text{k} \times $set s "$s$i"618.317.319.518.3
list_iterate$ — 5\text{k} \times $lappend, then foreach13.26.86.64.9

Mean ± σ over the same runs:

Benchmarktclsh 9.0.4tclrs interptclrs JITtclrs AOT
startup13.6 ± 0.75.1 ± 0.45.4 ± 0.53.2 ± 0.3
counted_loop_proc56.2 ± 3.1192.1 ± 3.76.5 ± 1.44.5 ± 0.1
counted_loop407.4 ± 9.6181.7 ± 3.96.4 ± 0.44.9 ± 0.3
counted_loop_expr480.5 ± 10.0188.0 ± 7.87.4 ± 0.35.3 ± 0.3
integer_arith298.7 ± 5.5149.0 ± 2.97.0 ± 0.44.7 ± 0.4
string_build823.8 ± 134.418.3 ± 0.620.7 ± 0.919.1 ± 0.5
list_iterate15.1 ± 1.87.5 ± 0.57.7 ± 1.45.2 ± 0.3

Every ratio below is the first table's numbers divided; nothing else is inferred.

Every arithmetic loop reaches native code now, with or without --aot. The three loop rows are within a millisecond or two of each other across the JIT and AOT columns, and all of them are a few milliseconds above startup:

tclshJITAOTJIT vs tclsh
counted_loop386.65.84.367×
counted_loop_expr461.97.04.766×
integer_arith284.76.44.244×

Both halves of that took a change. The ahead-of-time column was blocked on expr: every one used to end in an extension op that converted its result to Tcl's string form, fusevm's ahead-of-time compiler has no lowering for an extension op, and one deopt on the first iteration handed the whole loop back to the interpreter — counted_loop_expr took 251.5 ms compiled and integer_arith 165.1. Tcl's string form is now applied where a string is asked for, so an arithmetic loop lowers to native ops end to end.

The JIT column was blocked on where a Tcl variable lives. A top-level one is a VM global, fusevm's tiers took slots and not globals, and the three rows ran 221.1, 234.3 and 166.1 ms — interpreted, with the tracing recorder's overhead on top. fusevm 0.15.0 promotes the globals a trace references to registers at entry and spills them at every exit, guarded per referenced index; see JIT Compilation for why the guard cannot be the whole-table check the slot path uses.

What the JIT is worth on a loop it takes. counted_loop runs in 5.8 ms against 175.3 interpreted — 30× — with startup at 4.5 ms on the same run, so the 3,000,000 iterations are inside the noise of process startup. Scaling the script to 30,000,000 iterations does not move it out; ten times the iterations for the same wall clock is not a per-iteration cost at all — Cranelift can close a counted loop whose result is its own bound — so read that as the loop disappearing, not as nanoseconds per iteration.

tclsh's own ranking still splits on the procedure boundary: it runs the proc-local loop in 51.5 ms against 386.6 for the top-level one, a 7.5× spread on the same arithmetic, because it compiles a procedure's locals and not a script's globals. tclrs no longer splits at all — 5.6 against 5.8 ms — which is the practical difference: a hot loop no longer has to be wrapped in a proc to reach native code.

Where tclrs wins without any tier. Interpreted, tclrs is 36× tclsh on string_build, 2.6× on the counted loop written with expr, 2.2× on the top-level counted loop, 2.0× on integer arithmetic, 1.9× on list_iterate, and starts in 4.5 ms against tclsh's 12.4.

Where a tier still buys nothing. string_build and list_iterate spend their time in frontend extension ops — the in-place append, the list commands — which no tier lowers, so their three tclrs columns sit within a couple of milliseconds of each other and the interpreter is as fast as anything else. That is the remaining shape of the problem: what is left outside native code is the data-structure work, not the arithmetic.

lappend builds a list in place. list_iterate$ \text{was} \text{the} \text{one} \text{row} \text{tclrs} \text{lost}, \text{by} 14 \times : \text{the} \text{list} \text{lived} \text{in} \text{the} \text{variable} \text{as} \text{its} \text{string} \text{representation} \text{and} \text{every} $lappend re-derived the elements and re-quoted all of them, so building a list was quadratic. It is now linear. The op reaches the variable itself rather than taking its value through GetVar, so the string is unshared while the op runs and the new elements are appended to it; the value the last lappend produced is remembered by identity, which is what says the string is canonical without a scan to prove it (src/cmd_list.rs). A value another variable is holding is still copied — the shared string must not change under it — so the semantics are the ones tclsh has, and tests/list_differential.rs compares them against it.

Measured on the machine above, the same tree either side of the change, 15 runs after 3 warmup: the benchmark went from 435.7 ± 5.4 ms to 6.3 ± 0.4 ms, 69.7 ± 4.1×, and the shape changed with it. Building 5,000 / 50,000 / 500,000 elements now takes 1.3 / 13.5 / 154.5 ms — linear in the element count — against 48,455 ± 6,926 ms for 50,000 before, which is the quadratic curve. tclsh takes 163.8 ms for the 500,000-element run, so the two are level where tclsh used to be 2,000× ahead.

A list is split once per value, not once per command. lappend above is half of the problem; the other half is reading a list back. Every list command re-derives its elements from a string, so for {set i 0} {$i < $n} {incr i} {lindex $l $i} parsed the whole list once per turn and the loop was quadratic in its length — 0.595 / 2.382 / 9.538 s of CPU at 2,000 / 4,000 / 8,000 elements, against tclsh's 0.018 / 0.018 / 0.024, because a Tcl_Obj there carries a list representation beside its string and the parse happens once. src/cmd_list.rs now keeps the elements of the last few lists it split, keyed on the value's identity — a pointer comparison, with the entry holding the value so the address cannot be reused under it. The same three runs are 0.021 / 0.035 / 0.066 s, linear, and building a list is unaffected: an entry is a share of the string, so a cached list is copied rather than grown in place, and the copy has an identity of its own.

One compiled regular expression, not one per match. Patterns were already cached by text, but the entry was handed out as a clone of the regex::Regex — and a clone carries an empty pool of match caches, so the lazy DFA was rebuilt on every call. A profile of 200,000 matches had ByteClassRepresentatives::next and the whole regex_automata::hybrid state machinery at the top and the pattern compilation itself nowhere in it. The entry is an Arc<Regex> now, shared as the crate intends, and is keyed on the pattern as the script wrote it so the ARE-to-regex rewrite is paid once as well: the same loop went from 7.507 s of CPU to 1.925 s, against tclsh's 0.374 s. tests/regexp_differential.rs pins that a reused pattern answers as a fresh one would, that the flags are part of the key, and that a pattern which will not compile is refused on every call rather than only the first.

A chunk is entered without copying it. A procedure whose body was compiled into another chunk — every procedure defined inside eval, namespace eval or a sourced file — runs on a VM of its own over that chunk, and fusevm::VM::new takes a Chunk by value, so entering it copied the whole program per call. src/runtime.rs keeps a VM per chunk between runs: VM::reset clears every other part of the machine and takes the chunk by value, so the chunk is moved out of the VM and straight back into it and nothing is copied. 20,000 calls into a chunk of 600 procedures went from 17.276 s of CPU to 7.843 s. A VM is taken out of the pool while it runs, so a recursive or re-entrant call builds one of its own. What is left is not the copy: the same call into a small chunk is unchanged at about 2 s for 200,000 calls, against 0.009 s for the same procedure written at the top level and 0.088 s for tclsh, so the per-call scaffolding around the run — the variable projection in and out, the hooks, the interpreter lock — is the next thing to look at.

append builds a string in place, and so does set x "$x…". string_build is the same problem in the other data type, and it was the row where tclrs and tclsh were level: both copied the whole accumulated string every iteration, so a 100,000-iteration build moved about 24 GB of bytes to produce half a megabyte. append now reaches its variable the way lappend does and appends to the string the variable already holds; a string needs no canonical form, so no memory of the last value is needed to know that is safe — only that nothing else holds it. set x "$x…" is lowered as the same op whenever the word only grows x and nothing after that first $x can run a script, which is what keeps the read order the same as the word's (src/compiler.rs, src/cmd_string.rs).

Same tree either side, 12 runs after 3 warmup: bench/string_build.tcl went from 734.5 ± 153.6 ms to 26.8 ± 6.3 ms (minima 497.2 and 18.3), which is 27× on either statistic, and tclsh runs it in 535.2 ms at its own best. The benchmark also prints string length $s now, so a build that skipped the work would print the wrong number rather than a fast time.

What the JIT costs where it does not fire. The tclrs JIT column is the same binary as tclrs interp with the tracing JIT armed. On a script whose loops it cannot take it is not free: 13% slower on string_build, and within noise on list_iterate and startup. That is the recorder check in the dispatch loop plus the once-per-run block-tier lookup, paid on every script whether or not a trace is ever installed. It used to be paid on the counted loops too, for a tier that then refused them; those now return 30–44× for it. It stays on unconditionally because which of the two a script gets is not knowable before the script runs, and hiding the cost would make the table dishonest.

Caveats worth knowing before quoting any of this: the machine was not idle — a shared workstation at a load average near 12 — which is why the minima are the table and the means are the second table, and why string_build's tclsh row spreads over 400 ms between its best and worst run; every tclrs row on a loop benchmark is now within a few milliseconds of startup, so those ratios are bounded by process spawn rather than by the loop, and a larger iteration count is the way to see the loop itself; and the AOT rows run with the JIT armed too, since the ahead-of-time runtime hook goes through the same install point.


[0x0B] CONFORMANCE

The differential suites test what tclrs claims to do. conformance/ measures the opposite: how much of real Tcl it does, by running the Tcl project's own test suite against it.

29335 of 48201 attempted cases pass — 60.9%. Over every case the suite contains, including the ones that cannot be run here, that is 29335 of 69424 — 42.3%. conformance/REPORT.md has the breakdown behind the number: attempted, passed, failed and skipped per suite file, why each skipped case could not be run, and the failure causes ranked.

Regenerate it:

conformance/run.sh

That fetches the Tcl source release — the suite ships there, not in a binary install — verifies it against a pinned SHA-256, lifts every case out of every tests/*.test file using tcltest's own argument parsing, runs each one under both tclsh and tclrs, and rewrites the report. No file and no case is chosen by hand, and the runner has no option to run a subset. A case passes only when the two runs agree on the whole triple of exit code, result string and stdout, byte for byte; the suite's own -result values are not consulted, because tclsh is the specification and comparing against what it actually does is stricter than comparing against what the suite says it should.

Read the denominator with the numerator, always. A command landing moves cases out of the skip column and into the attempted one, so the share can fall while the tree gets better, and it has: an earlier report — taken before proc, the string ensemble, coroutines and eval — passed 1404 of 2941, or 47.7%, and the report after them passed more cases at a lower share. A number that only ever rises is measuring the wrong thing.

encoding landing moved the number the other way, because it was the single largest blocker in the suite: it alone was the first command refused in 16,856 cases. Before it, 12720 of 31524 attempted passed — 40.4% — with 37900 of the 69424 extracted cases skipped, 19486 of those for a command tclrs did not have. After it, 29229 of 48324 passed — 60.5% — with 21100 skipped and 2686 of those for a missing command. So the denominator grew by 16,800 and the share rose 20.1 points; encoding no longer appears in the blocking table at all.

Merging the published line's info, uplevel and apply in moved it again, and in both directions at once: 29335 of 48201 pass now — 60.9%, 106 more cases and 229 fewer failures — while the skip column grew by 123, to 21223. That is the same effect read from the other end. A case is attributed to the first command tclrs refused, so a body that used to fail on info body or on a computed uplevel level now runs further into itself and reaches whatever it needs next: oo::class went from blocking 171 cases to 259, trace from 259 to 264. No new command appeared in the blocking table; the largest entry is still binary, at 690 cases. The skip breakdown is 2809 / 13663 / 4751 / 0 (missing command, unmet constraint, a command plain tclsh has not got, no reference outcome) — the two middle rows are properties of the suite and this machine, and neither has ever moved.

The Tk suite

tk-conformance/ is the same measurement pointed at Tk. The candidate is not a reimplementation: it is the same libtcl9tk9.0.dylib the reference loads, running against tclrs's own Tcl stub table, so what is being measured is how much of the real toolkit this frontend can host.

1655 of 5055 attempted cases pass — 32.7%. Over every case the suite contains that is 1655 of 10046 — 16.5%. tk-conformance/REPORT.md has the breakdown, including a ranked list of the stub slots that ended a run — Tcl_SplitList alone stops 1893 cases — which is what the number is waiting on.

One classification rule differs from the Tcl harness, and it is the stricter one: a call to a stub slot with no body ends the process, and that counts as a failure rather than a skip. invalid command name is tclrs declining and saying so; a trap is the process dying, and a process that died measured nothing.

The report also runs Tk's own demos/widget — the sample application wish ships with — one statement at a time. It gets 2 of its 65 statements in before package require msgcat stops it; attempted individually, 28 complete and 37 do not. The largest remaining refusal there is a command name the script computes ($w insert, $w configure), which stops five of the 37; {*} argument expansion stopped four of them until ext::EXPAND_CALL landed and no longer appears.

tk-conformance/run.sh

That needs a window server: both sides open real windows, which is the point.


[0x0C] TESTING

cargo test

Every suite compares against the reference interpreter rather than against hand-written expectations: each program is executed by both tclsh and tclrs and the outputs compared byte for byte. The suites cover the twelve parse rules, word splitting character for character, whole programs, the list commands, the associative commands, the string ensemble, procedures and control flow, coroutines, the interpreter's state across evaluations, the binary's stdout / stderr / exit status in each of its input modes, transcoding, and the ahead-of-time path against the interpreter.

tests/encoding_differential.rs is the widest of them, because tables are what it is checking: two of its cases are sweeps that put every single byte through every encoding under every profile, in both directions and with -failindex, and every two-byte sequence through the encodings where the second byte decides the character. Both are generated in Tcl so that the two interpreters run identical text, and both compare line for line. The sweep that found the bugs during development was wider still — every two-byte sequence in all 92 encodings, 10.7 million comparisons — and it agreed byte for byte up to the point where tclsh could no longer write its own answer to a UTF-8 channel.

Three suites drive the binary rather than the library: tests/lsp_session.rs and tests/dap_session.rs speak the real protocols to the real process over stdio — handshake, diagnostics, breakpoints, stepping, and a shutdown that exits — and tests/rust_ffi.rs runs a script with a rust { ... } block in it, so rustc is invoked, the library is loaded and the exported function is called, rather than the test stopping at the desugaring.

Several of them generate their cases rather than listing them: every awkward element value driven through every list command, foreach through every shape its grammar allows, the glob matcher over a pattern × subject grid, and every index form against lists of every length — each matrix run as one script and compared line for line.

The differential suites skip when no tclsh is on PATH. The full ahead-of-time link test skips when libtclrs.a has not been built or there is no cc.

Examples

examples/ holds runnable programs, one per slice of the language — variables and substitution, expr, control flow, procedures, lists, strings, dict and array, errors, coroutines, eval, the event loop and the scope commands, and a FizzBuzz that prints. Run one directly:

cargo run --bin tclrs -- examples/lists.tcl

Each is self-checking: results go through a check procedure that raises a Tcl error — so a non-zero exit — the moment one drifts. tests/examples.rs gates them twice. Every script has to exit cleanly under the built binary, which needs no Tcl installed and so runs anywhere; and every script's stdout has to match tclsh byte for byte, which is what keeps an expectation written into a script from being wrong in the same direction as the implementation. The second test skips when no tclsh is on PATH, like the other differential suites.

Differential fuzzing

cargo build
bash scripts/fuzz_parity.sh -n 400 -s 1 -m

scripts/fuzz/gen.tcl generates whole Tcl programs from a seed — the same seed gives the byte-identical corpus, so a divergence reproduces from the seed and the case index alone — and scripts/fuzz_parity.sh runs every one under both tclsh and tclrs through one driver, scripts/fuzz/drive.tcl. Loop bounds are structural, so a generated program always terminates, and values come from a pool of awkward literals: empty strings, braces, brackets, quotes, backslashes, $, a leading #, leading zeros, 1_0, 0d9, 0x_10, -0, nan and inf, the i64 boundaries from both sides, exponent-form floats, list-shaped strings where a scalar is expected, and multi-byte text with the non-ASCII character at a string boundary — including astral-plane characters.

What the generator builds rather than lists: format's specifier matrix (flags × width × precision × conversion, * included), the lsearch and lsort option matrices, and every string subcommand in every argument shape its synopsis allows. Programs are stateful as well as nested — coroutines resumed from a counted loop, from inside a procedure and inside a catch, procedures that call procedures along an acyclic call graph, and eval nested several levels deep.

Shapes tclrs recognised and refusedarray on a procedure local, lsort -command, string is punct, eval inside a procedure body — were generated on purpose at a low rate rather than avoided, so that the coverage would already be in place on the day each refusal went. Every one of them has since gone: at seed 1, depth 4, 200 cases the skip bucket is 1, and the one entry in it is format %a. The rate is still one number in the generator (RARE_SHAPE_RATE), and it now weights the corner of each command against its middle rather than trading a comparison for a skip. Whether it should rise, now that these cost a comparison nothing, is an open question and a change of its own: raising it moves what every seed generates.

The generator has not caught up in the other direction either — it under-measures the constructs it still draws at the rare rate, and uplevel and apply are not generated at all. What covers those meanwhile is tests/frame_differential.rs.

bash scripts/fuzz_parity.sh -M -n 500 -m       # mutate instead of generate

-M builds the corpus from the committed findings in tests/fuzz_corpus (plus anything -c names) instead of generating fresh programs: scripts/fuzz/mutate.pl splices statements between cases, duplicates and deletes lines, swaps lines, perturbs literals and swaps operators. It is seeded and reproducible exactly as generation is, and it writes the same corpus format, so the split, the classifier and the shrinker are the same code — no case is classified two ways. Termination is preserved rather than re-derived: a loop-bearing line is only ever moved, duplicated or deleted whole, nothing is inserted into a body, and a mutant whose loops are not verbatim from a source case — or in which a loop's counter is assigned off the loop's own line — is redrawn.

Every case lands in exactly one bucket, and every bucket is counted: pass, skip (tclrs refused something it documents as unimplemented, with the refusal's own wording as the reason), allowed (one of the enumerated known divergences, each with a per-entry hit count so an over-broad suppression is visible rather than silent), divergence, critical (tclrs died or hung — never suppressible), and excluded (tclsh died or hung, so there is no reference behavior; never charged against tclrs). -m minimises each divergence to the statement that causes it and writes it to tests/fuzz_corpus/ with both engines' observed output; tests/parity_fuzz_corpus.rs replays that corpus, and tests/parity_fuzz_findings.rs pins each finding against a live tclsh. The exit status is the number of unsuppressed divergences, capped at 250. -h prints the whole interface.

What it has found is BUGS.md.

A second fuzzer needs no tclsh: fuzz/fuzz_targets/ holds cargo-fuzz targets for the inputs a grammar never produces — a lone \x00, a truncated escape, thousands of nested brackets.

targetsurface
parsethe command language, on arbitrary bytes
compilerparse and lowering, without running anything
exprthe expression grammar, which parse never reaches
evala generated script, compiled and run
vmone chunk run twice, on two interpreters
cargo +nightly fuzz run parse -- -max_total_time=1500
cargo +nightly fuzz run expr  -- -max_total_time=1500 -max_len=32768

expr wants the larger -max_len: its deepest seed is the 16 KB of nested parentheses that used to abort the process, and libfuzzer skips a seed above the default 4 KB.

eval and vm do not feed their bytes to the VM. A byte string is a weak input for a runtime — almost every mutation of one is a parse error, so nothing executes — so fuzz/fuzz_targets/shared.rs reads the input as a sequence of fragments and builds a Tcl program from fixed command skeletons, with the fuzzer's bytes as the arguments. That is where the crashes have been: a format field width, a string repeat count, a list index. Every generated loop counts to a literal and no command in this frontend touches the filesystem, so a generated script terminates and a libfuzzer timeout is a real finding.

tests/fuzz_smoke.rs replays every target's seed corpus and a hostile-input list under stable, so cargo test keeps the scaffolding honest without nightly.


[0xFF] LICENSE

MIT — free and open source. See LICENSE.