>_TCLRS REFERENCE
A Tcl frontend written in Rust. A script is parsed once — every substitution the grammar permits resolved at parse time — and each command is lowered to fusevm bytecode, the same bytecode sixteen other language frontends emit. No bespoke VM, no interpreter loop, no code generator in this crate. The reference implementation is tclsh 9.0.4, and the test suite compares against it directly. In active development.
What it is
tclrs is the seventeenth frontend on fusevm, the shared bytecode VM behind zshrs (the shell), stryke (the language), awkrs (AWK), vimlrs (VimL), elisprs (Emacs Lisp), rubylang (Ruby), pythonrs (Python), phplang (PHP), node-js (JavaScript), rlang (R), go-rs (Go), arb, and the four JVM frontends. It carries no VM and no code generator of its own: tclsh runs Tcl on its own bytecode engine, tclrs runs Tcl on fusevm's, including fusevm's Cranelift JIT and its ahead-of-time compiler.
Two properties of Tcl's grammar make ahead-of-time lowering worthwhile. Braces suppress substitution, so a braced body is fully known at parse time and compiles once into bytecode instead of being re-parsed on every evaluation — words carry a braced flag for exactly that decision. And 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.
Architecture
The pipeline mirrors how zshrs hosts zsh and groovyrs hosts Groovy:
Tcl script → parser (Script/Command/Word) → lower to fusevm bytecode → fusevm VM
│
numeric hook (string operands, integer overflow)
extension ops (floored / and %, integral **, normalize)
fusevm-hosted
No local vm.rs / jit.rs. Each command lowers into a fusevm::Chunk and runs on the shared VM (src/compiler.rs).
No object heap
Tcl's value model needs none on top of fusevm's: strings, integers and floats map onto Value directly, and a value keeps its numeric representation until something demands its string form.
Native arithmetic
+ - *, the comparisons, the bitwise and shift operators, and short-circuiting && / || lower to native fusevm ops, so the arithmetic the JIT tiers care about stays visible to them.
Static stack tracking
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.
Example
set x 5
set y [expr {$x * 2}]
puts "x=$x y=$y" ;# => x=5 y=10
puts [expr {-57 / 10}] ;# => -6 (Tcl floors toward -inf)
puts [expr {-57 % 10}] ;# => 3
puts [expr {2**10}] ;# => 1024 (integral ** stays integral)
puts [expr {3.0/2}] ;# => 1.5 (Tcl double formatting)
puts [expr {"10" < "9"}] ;# => 0 (numeric-preferring comparison)
puts [expr {"abc" lt "abd"}] ;# => 1 (always-string comparison)
set i 0
while {$i < 3} {
incr i
if {$i == 2} { continue }
puts $i ;# => 1, then 3
}
Every program in the test suite — this one included — is run through both tclsh and tclrs and the output compared byte for byte. No expected output in the repository is written by hand.
Status & roadmap
The table below reflects the current state of the tree. See BUGS.md for the honest ledger of what is not yet carried.
| Component | State | Notes |
|---|---|---|
Parser — the twelve rules of Tcl(n) | Implemented | Command/word splitting, quotes, braces, command and variable substitution, the full backslash table, first-word comments, single-pass order (src/parser.rs). |
| Script → fusevm bytecode lowering | Implemented | Native ops plus frontend extension ops; statically tracked stack depth, backpatched control flow (src/compiler.rs). |
Commands set / puts / expr / incr | Implemented | puts -nonewline supported; command substitution of any implemented command. |
Control flow if / while / foreach / break / continue | Implemented | elseif / else chains; loop exits unwind by a compile-time-known pop count. foreach takes any number of variable lists and value lists, its state carried on the VM stack. |
expr — the full expr(n) operator set | Implemented | Compiled straight from a braced word with no runtime parse, at expr(n) precedence (src/expr.rs). |
| Tcl arithmetic & number formatting | Implemented | Floored / and %, integral **, numeric-preferring comparison with string fallback, Tcl double format (src/runtime.rs). |
Differential suites vs tclsh | Implemented | Parse rules, word splitting, whole-program execution, and generated matrices for list quoting, foreach shapes, glob patterns and index forms, all compared against tclsh 9.0.4; skipped when no tclsh is installed. |
Lists — parsing, canonical quoting, the thirteen list commands, foreach, in / ni | Implemented | String ⇄ elements ported from TclFindElement and TclScanElement / TclConvertElement (src/list.rs); the commands and lsort's reference merge sort in src/cmd_list.rs. |
Procedures — proc, return, global | Implemented | Parameters and locals are frame slots, not globals; signatures collected before emission, so a procedure may call one defined further down (src/procs.rs). |
catch / error, for, switch | Implemented | A catch region records its handler's op index; the driver unwinds the value stack and the call frames to the region's entry state and resumes there (src/control.rs). |
Associative data — array variables, array, dict, unset | Implemented | Arrays live in the VM's global table keyed by name index (src/assoc.rs). |
Strings — the string ensemble, append, format | Implemented | src/cmd_string.rs. Subcommands and conversions outside the implemented set are refused by name. |
Coroutines — coroutine, yield, yieldto, info coroutine | Implemented | A coroutine is a second fusevm::VM over the same chunk; the driver owns the transfer and the one global table every context shares (src/coro.rs). |
The tclrs binary, the REPL, and eval | Implemented | A file, -c script, or stdin, with tclsh's exit statuses and stderr wording. A terminal gets a reedline editor — history, completion drawn from the compiler's own tables, multi-line editing decided by the parser, and procedures that outlive the line defining them; a pipe gets the silent loop. An Interp holds the variables between evaluations, over a source-keyed chunk cache (src/main.rs, src/repl.rs, src/repl_line.rs, src/cache.rs). |
Ahead-of-time compilation — --aot | Implemented | fusevm's closed-world compiler emits a relocatable object that links against libtclrs.a into a standalone binary. Refused for a script using catch or a coroutine, which need a driver outside VM::run (src/aot.rs). |
JIT tiers armed, and reported — --tiers | Compiles every counted loop | Every VM arms fusevm's tracing JIT. Every counted while or for loop reaches a compiled trace — traced=true, reaches native code true — whether its counter is a procedure's frame slot or a script's top-level VM global, because every loop is emitted rotated into the do-while shape fusevm's trace compiler accepts and fusevm promotes the globals a trace references to registers at entry. Measured on all three of bench/counted_loop.tcl, counted_loop_proc.tcl and counted_loop_expr.tcl. What a loop still fails on is an extension op in its body: foreach and dict for reach nothing, their loop state being frontend extension ops. All measured, not assumed (src/tiers.rs). |
{*} argument expansion | Implemented | A command containing one is lowered whole — the line, a flag and a value per word, then ext::EXPAND_CALL — and its flagged words are spliced by list rules when it runs, which is the only moment the argument count exists. The callee may be a procedure of the interpreter, a command this frontend compiles (rebuilt as a list and evaluated, so set {*}{a b} assigns), or a command Tk registered (src/procs.rs). |
| Conformance against the official Tcl test suite | Measured | 43661 of 48845 attempted cases pass — 89.4%; over every case the suite contains, 43661 of 69424 — 62.9%. The suite is fetched and checksum-verified, every case lifted out and run under both interpreters; regenerate with conformance/run.sh. Read the denominator with the numerator: a command landing moves cases out of the skip column into the attempted one, so the share can fall while the tree improves. |
| Conformance against the official Tk test suite | Measured | 1655 of 5055 attempted cases pass — 32.7%. The candidate is not a reimplementation of Tk: it is the same libtcl9tk9.0.dylib the reference loads, driven through this crate's own Tcl stub table, 200 of whose 691 slots have bodies. Regenerate with tk-conformance/run.sh, which needs a window server because both sides open real windows. |
Regular expressions — regexp, regsub | Implemented | -nocase, -all, -inline, -indices, -line, -lineanchor, -linestop, -expanded, -start and --, plus switch -regexp and lsearch -regexp. Back-references and look-ahead are refused by name rather than approximated, the engine being a finite automaton (src/regexp.rs). |
Running in another frame — eval, uplevel, upvar, apply | Implemented | A script built at run time is compiled against the caller's state: eval in a body, and uplevel into a procedure activation, run against a projection of that frame, built from the slot names proc records in the chunk (fusevm::Chunk::sub_slot_names). uplevel reads which word is the level when the command runs, as Tcl_UplevelObjCmd does, so uplevel $n {…} works. upvar binds a live link at any level, with a computed level, a computed target or an array element. apply runs a lambda as the procedure it is, computed or written out, and reports a wrong argument count against apply lambdaExpr. |
Math functions, channel IO, namespace, clock, file, encoding, the event loop, package | Implemented | Each was a row of its own on this list until the module behind it landed: src/expr_math.rs (all 37 of mathfunc(n)), src/cmd_channel.rs, src/cmd_namespace.rs, src/cmd_clock.rs, src/cmd_file.rs, src/cmd_encoding.rs with Tcl's own .enc tables under src/encodings/, src/cmd_after.rs, src/cmd_package.rs. What each still declines is named in BUGS.md; an unknown command is reported when the command runs, never a silent miss. |
| Arbitrary-precision integers | Implemented | Tcl 9's integers are unbounded, and an i64 overflow is a promotion rather than an error — through the numeric hook in the interpreter, and under the JIT and ahead-of-time compiled too, because every chunk carries int_overflow_deopt so native arithmetic deopts into the hook instead of wrapping. |
Editor servers — --lsp, --dap | Implemented | --lsp answers diagnostics, completion, hover, signature help and document symbols from the same tables the REPL completes from, deciding what is under the cursor with the module the prompt uses (src/lsp.rs, src/cursor.rs). --dap gives breakpoints, stepping, stack frame and variables by stopping on markers compiler::compile_debug emits and an ordinary compilation does not, so nothing is paid for a debugger that is not attached (src/dap.rs). Both are driven over the wire against the real process by tests/lsp_session.rs and tests/dap_session.rs. |
Inline Rust — rust { ... } | Implemented | A block is rewritten before parsing into __rust_compile, compiled to a shared library through fusevm::ffi and cached under ~/.cache/fusevm/ffi by the SHA-256 of its body; its exports become Tcl commands, registered while the block is lowered rather than when the VM runs (src/rust_ffi.rs). |
| The rest of the toolchain — dumps, completion, man pages | Implemented | --disasm, --dump-tokens and --dump-ast print the bytecode, the lexical output and the parse tree; the zsh completion is completions/_tclrs; the manual pages are tclrs(1) and the all-in-one tclrsall(1). |
Why tclrs
Lowered once
Braced bodies and braced expr expressions are known at parse time, so they compile once into bytecode instead of being re-parsed per evaluation.
No string round-trips
A value produced as a number stays a number in a VM slot and only acquires a string representation when something asks for one.
One shared engine
VM, JIT and ahead-of-time work in fusevm benefits zshrs, stryke, awkrs, vimlrs, elisprs, rubylang, pythonrs, phplang, node-js, rlang, go-rs, arb, the JVM frontends, and tclrs at once.
Ported, not reinvented
tclsh 9.0.4 is the specification. Expectations are never hand-written: the suites diff tclrs against the reference interpreter directly.
Building from source
tclrs builds as a standalone Rust crate (it is not a workspace member of the meta repo):
# clone git clone https://github.com/MenkeTechnologies/tclrs cd tclrs # build cargo build # run the suites cargo test
The differential suites 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.
Using the library
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 variables kept between calls — what a REPL needs and what the eval command needs. tclrs::parse returns the parsed Script without running it, for tooling that wants the word structure.
License
tclrs is MIT licensed — free and open source. See LICENSE.
Repository & links
- Engineering report — report.html (architecture, value model, component status, dependency posture)
- Command reference — reference.html (every command, ensemble subcommand,
exproperator and operand shape,string isclass andformatconversion, each with a signature and a description — generated from the compiler's own tables bycargo run --bin gen-docs) - Known gaps — BUGS.md
- Runnable programs — examples/, one per slice of the language, each checking its own results and gated against tclsh byte for byte
- Source — github.com/MenkeTechnologies/tclrs
- Issues — github.com/MenkeTechnologies/tclrs/issues
- The shared VM — fusevm (also behind
zshrs,stryke,awkrs,vimlrs,elisprs,rubylang,pythonrs,phplang,node-js,rlang,go-rs)