tclrs 0.4.10

Tcl as a fusevm frontend: a parser and compiler to fusevm::Chunk, with no bespoke VM or JIT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
```
████████╗ ██████╗██╗     ██████╗ ███████╗
╚══██╔══╝██╔════╝██║     ██╔══██╗██╔════╝
   ██║   ██║     ██║     ██████╔╝███████╗
   ██║   ██║     ██║     ██╔══██╗╚════██║
   ██║   ╚██████╗███████╗██║  ██║███████║
   ╚═╝    ╚═════╝╚══════╝╚═╝  ╚═╝╚══════╝
```

![Rust](https://img.shields.io/badge/Rust-2021-05d9e8?style=flat-square)
![license](https://img.shields.io/badge/license-MIT-ff2a6d?style=flat-square)
![status](https://img.shields.io/badge/status-in%20development-9b5de5?style=flat-square)

### `[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`](https://github.com/MenkeTechnologies/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](#0x00-overview)
- [\[0x01\] Build](#0x01-build)
- [\[0x02\] The Binary](#0x02-the-binary)
- [\[0x03\] The Library](#0x03-the-library)
- [\[0x04\] Language Surface](#0x04-language-surface)
- [\[0x05\] What Is Refused](#0x05-what-is-refused)
- [\[0x06\] The Parser](#0x06-the-parser)
- [\[0x07\] Architecture](#0x07-architecture)
- [\[0x08\] JIT Compilation](#0x08-jit-compilation)
- [\[0x09\] Ahead-of-Time Compilation](#0x09-ahead-of-time-compilation)
- [\[0x0A\] Benchmarks](#0x0a-benchmarks)
- [\[0x0B\] Conformance](#0x0b-conformance)
- [\[0x0C\] Testing](#0x0c-testing)
- [\[0xFF\] License](#0xff-license)

---

## [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 2^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 time** — `tclrs --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](#0x08-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:

```sh
brew tap MenkeTechnologies/menketech
brew install tclrs
```

From source:

```sh
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 { ... }`](#inline-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

```text
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
`dlopen`s 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`](completions/_tclrs) — put that
directory on `fpath`. The manual pages are [`man/man1/tclrs.1`](man/man1/tclrs.1) and the all-in-one
[`man/man1/tclrsall.1`](man/man1/tclrsall.1): `man ./man/man1/tclrsall.1`.

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

| Behavior | What happens |
| --- | --- |
| A script file | One 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 terminal | A 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 terminal | The same evaluation, driven by a line editor: prompt, history, completion, multi-line editing, and the value of each command echoed. See [The REPL](#the-repl). |
| `argv0`, `argc`, `argv` | Set before the script runs, as `tclsh` sets them. |
| Errors | stderr 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`](https://crates.io/crates/reedline) line editor.
A pipe does not: `tclrs < script` is still the silent loop, byte for byte.

```text
─( 14:52:07 )──< command 3 >──────────────────────{ tclrs 0.3.0 }─
tclrs❯ proc double {x} {
····❯   expr {$x * 2}
····❯ }
tclrs❯ double 21
42
```

| | |
| --- | --- |
| Multi-line editing | A 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. |
| Completion | Tab 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. |
| Procedures | A 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. |
| Keys | Emacs 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. |
| Leaving | `exit`, `exit N`, `quit`, or Ctrl-D. Ctrl-C abandons the line being typed. |

### Options that do not run the script the ordinary way

```sh
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:

```text
$ 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.

| Capability | Where the answer comes from |
| --- | --- |
| Diagnostics | The 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. |
| Completion | Command 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 help | The 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 symbols | The `proc` commands the parser found. |

What is under the cursor is decided by [`src/cursor.rs`](src/cursor.rs), the
module the REPL's completer uses, so the editor and the prompt agree.
[`tests/lsp_session.rs`](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:

```tcl
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,
`dlopen`ing and marshalling belong to
[`fusevm::ffi`](https://github.com/MenkeTechnologies/fusevm); 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](#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`](#0x09-ahead-of-time-compilation) 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:

```rust
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:

```rust
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"));
```

| Entry | What it is for |
| --- | --- |
| `Interp::new` | Scripts write to the process's stdout, through one buffered writer flushed at the end of each evaluation. |
| `Interp::capturing` | Scripts' writes are collected for `Interp::take_output`. |
| `Interp::set_global` / `Interp::global` | Host access to the interpreter's variables. |
| `Interp::set_recursion_limit` | How 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_DEPTH` | How 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_captured` | `eval` for a caller that wants both halves of a failing run: the error *and* whatever the script had already printed. |
| `tclrs::parse` | The parsed `Script` without running it, for tooling that wants the word structure. |
| `tclrs::aot` | `compile_object`, `compile_executable`, and `run_native` — the same codegen driven in-process. |
| `tclrs::tiers` | `report` and `inspect`: which fusevm tiers a chunk reaches. |
| `tclrs::dump` | `tokens` and `ast`: the two listings [`--dump-tokens`](#options-that-do-not-run-the-script-the-ordinary-way) and `--dump-ast` print. |
| `tclrs::lsp` | `run_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::dap` | `run_stdio`: the debug adapter, over stdio. |
| `tclrs::cursor` | `word_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

| Group | Commands |
| --- | --- |
| Variables | `set`, `incr`, `unset`, `append`, array variables (`a(k)`), `global`, `variable`, `upvar #0` |
| Output | `puts`, with `-nonewline` and an optional channel |
| Expressions | `expr` |
| Control flow | `if` / `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` |
| Procedures | `proc`, `return` (with `-code` — `ok`, `error`, `return`, `break`, `continue` or any integer — and `-level`), `apply` |
| Errors | `catch` (with a result variable and an options variable), `try` (`on`, `trap`, `finally`), `error`, `throw`, `return -options`; `-errorcode` for the errors builtins raise, where tclsh's message template determines it; 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 |
| Coroutines | `coroutine`, `yield`, `yieldto`, `info coroutine` |
| Namespaces | `namespace` — `eval`, `current`, `qualifiers`, `tail`, `parent`, `children`, `exists`, `delete`, `code`, `inscope`, `export`, `import`, `forget`, `origin`, `which`, `ensemble exists` / `create` / `configure`; `variable`; `rename` |
| The event loop | `after` — `ms`, `ms script`, `idle script`, `cancel`, `info`; `update`, `update idletasks`; `vwait` |
| Scope | `uplevel`, `upvar`, `apply` |
| Introspection | `info` — `args`, `body`, `commands`, `complete`, `coroutine`, `default`, `exists`, `functions`, `globals`, `hostname`, `level`, `library`, `locals`, `nameofexecutable`, `patchlevel`, `procs`, `script`, `sharedlibextension`, `tclversion`, `vars` |
| Packages | `package` — `files`, `forget`, `ifneeded`, `names`, `prefer`, `present`, `provide`, `require`, `unknown`, `vcompare`, `versions`, `vsatisfies` |
| Run-time evaluation | `eval`, `subst`, `source`, `tcl_findLibrary` |
| Lists | `list`, `llength`, `lindex`, `lappend`, `lrange`, `lreverse`, `linsert`, `lreplace`, `lsearch`, `lsort`, `join`, `split`, `concat` |
| Associative data | `array` — `exists`, `get`, `names` (`-exact`, `-glob`, `-regexp`), `set`, `size`, `unset`; `dict` — `append`, `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 expressions | `regexp`, `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 |
| Strings | `format`, `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` |
| Channels | `open`, `close`, `gets`, `read`, `flush`, `eof`, `seek`, `tell`, `fconfigure`, and `puts` to a channel; `stdin`, `stdout` and `stderr` |
| Math functions | The 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` |
| Time | `clock` — `seconds`, `milliseconds`, `microseconds`, `clicks`, `format`, `scan` (with `-format`), `add`; `-gmt`, `-timezone` (a numeric offset or any zone with a `TZif` file) and the root locale |
| Encodings | `encoding` — `convertfrom`, `convertto` (with `-profile tcl8` / `strict` / `replace` and `-failindex`), `dirs`, `names`, `profiles`, `system`, `user`; see [Encodings](#encodings) for which |
| Binary data | `binary` — `format` 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` |
| Filesystem | `file` — `atime`, `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)`:

| Group | Operators |
| --- | --- |
| 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 comparison | `lt gt le ge eq ne` — always string |
| Bitwise / shift | `& ^ \| ~ << >>` |
| Logical | `&& \|\| !`, short-circuiting; the ternary `?:` |
| Membership | `in` `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 **condition** — `if`, `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.

| Piece | How |
| --- | --- |
| **Parsing** | `TclFindElement`: 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. |
| **Formatting** | `TclScanElement` / `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. |
| **Indices** | `end`, `end±n`, `m±n` and the integer grammar (`0x` / `0o` / `0b` / `0d` prefixes, `_` separators), resolved as `Tcl_GetIntForIndex` resolves them. |
| **`lsort`** | The 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. |
| **`lsearch`** | The reference option parsing, including unique-prefix abbreviation and the rule that `-integer` / `-real` only apply in `-exact` mode. |
| **`foreach`** | Any 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. |
| **`lappend`** | The 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 `llength`** | The 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](#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 `::foo` | Reaches |
| --- | --- |
| `set v 1` | the interpreter variable `foo::v` |
| `proc p {…} {…}` | a procedure registered as `foo::p` |
| `p` | `::foo::p` if there is one, otherwise `::p` — `TclGetNamespaceForQualName`'s two-step search |
| `variable v` inside a procedure | links the local name `v` to `::foo::v` |
| `global v` inside a procedure | the 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`](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`](BUGS.md) is the ledger.

| Refused | Message |
| --- | --- |
| Any command outside [the list above](#0x04-language-surface) | `invalid 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 catalogue | `clock scan: the free-form parser is not supported yet; use -format` |
| A `clock` instant before the Gregorian changeover, where the calendar depends on the locale | `clock: dates before the Gregorian changeover of 1752-09-14 are not supported yet` |
| `file attributes`, `link`, `channels`, `system`, `tempfile`, `tempdir`, `volumes` | `file link 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` / `unset` | the word is refused where a literal is required |
| An array variable in a `foreach` variable list | `array variables are not supported yet` |
| `array startsearch` and the other search subcommands | `array 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](BUGS.md); `dict set`, `dict incr`, `dict update` or `dict with` into an array element; `dict update`'s variable names when they are not literal | `dict info is not supported yet` |
| `string wordend` / `wordstart` past ASCII | `string 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.md](BUGS.md) | `the "%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 list | `regexp -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 runs | `redefining the built-in command "set" is not supported` |
| `return -errorinfo`. `-code`, `-level`, `-errorcode` and `-options` are implemented, and so is `catch`'s options variable — which carries `-code`, `-level` and `-errorcode` and not tclsh's `-errorstack` / `-errorinfo` / `-errorline` | `return 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 script | `yield 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` pattern | `a 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 procedure | `bad 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 level | `info 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 is | `no 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 computed | `the 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 | `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 channel | `unknown 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 converts | `encoding: 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 substituted | `encoding 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 not | the 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 survives | `too many nested substitutions (infinite loop?)` |
| Ahead-of-time compilation of a script using `catch`, `try` or a coroutine | `ahead-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)`:

| Rule | Covered by |
|---|---|
| 1 Commands, 3 Words | command and word splitting, line tracking |
| 2 Evaluation | words retained in order for the compiler |
| 4 Double quotes | quoted words with substitution |
| 5 Argument expansion | `{*}` recorded on the word, spliced by `ext::EXPAND_CALL` when the command runs |
| 6 Braces | nesting, literal text, backslash retention |
| 7 Command substitution | nested scripts parsed eagerly |
| 8 Variable substitution | `$name`, `$name(index)`, `${name}`, `${name(index)}` |
| 9 Backslash substitution | full escape table, including the backslash-newline pre-pass |
| 10 Comments | `#` in first-word position only |
| 11, 12 Order and word boundaries | single 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
```

| Piece | How |
| --- | --- |
| **fusevm-hosted** | No local `vm.rs` / `jit.rs`. Each command lowers into a `fusevm::Chunk` and runs on the shared VM. |
| **`Interp`** | The 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. |
| **`Machine`** | One 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 point** | The 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 hook** | Catches 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 2^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 heap** | Tcl'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:

```toml
fusevm = { version = "0.26.0", features = ["jit", "jit-disk-cache", "aot", "ffi"] }
```

| Feature | What it adds |
| --- | --- |
| `jit` | fusevm's Cranelift tiers — linear, block, tracing. |
| `jit-disk-cache` | Compiled 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`. |
| `aot` | The closed-world compiler behind [`--aot`](#0x09-ahead-of-time-compilation). |
| `ffi` | The compile-and-`dlopen` path behind [`rust { ... }`](#inline-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

```tcl
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 — declined | After — traced |
| --- | --- |
| `05 GetSlot(0)` ← anchor | `05 Jump(12)` |
| `06 LoadInt(300000)` | `06 GetSlot(0)` ← anchor |
| `07 NumLt` | `07 LoadInt(1)` |
| `08 JumpIfFalse(16)` | `08 Add` |
| `09 GetSlot(0)` | `09 Dup` |
| `10 LoadInt(1)` | `10 SetSlot(0)` |
| `11 Add` | `11 Pop` |
| `12 Dup` | `12 GetSlot(0)` |
| `13 SetSlot(0)` | `13 LoadInt(300000)` |
| `14 Pop` | `14 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:

```tcl
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:

```tcl
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}
```

| loop | condition | ops | trace-eligible | traced |
| --- | --- | --- | --- | --- |
| `h2` | `$i < $n` | 29 | true | true |
| `h` | `$go` | 42 | false | false |

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.

```sh
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](#0x0a-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`, `try` and coroutines are refused.** They 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:

```sh
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](https://github.com/sharkdp/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:

| Row | Command |
| --- | --- |
| `tclsh` | `env tclsh SCRIPT` |
| `tclrs interp` | `env TCLRS_JIT=off target/release/tclrs SCRIPT` |
| `tclrs JIT` | `env TCLRS_JIT=on target/release/tclrs SCRIPT` |
| `tclrs AOT` | `env 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:

| Benchmark | tclsh 9.0.4 | tclrs interp | tclrs JIT | tclrs AOT |
| --- | ---: | ---: | ---: | ---: |
| `startup` — the empty script | 12.4 | 4.5 | 4.5 | **2.9** |
| `counted_loop_proc` — 3M × `incr`, inside a `proc` | 51.5 | 185.9 | 5.6 | **4.3** |
| `counted_loop` — 3M × `incr`, at the top level | 386.6 | 175.3 | 5.8 | **4.3** |
| `counted_loop_expr` — 3M × `set i [expr {$i + 1}]` | 461.9 | 175.8 | 7.0 | **4.7** |
| `integer_arith` — 1M × `$sum + $i * $i - ($i >> 3)` | 284.7 | 143.6 | 6.4 | **4.2** |
| `string_build` — 100k × `set s "$s$i"` | 618.3 | **17.3** | 19.5 | 18.3 |
| `list_iterate` — 5k × `lappend`, then `foreach` | 13.2 | 6.8 | 6.6 | **4.9** |

Mean ± σ over the same runs:

| Benchmark | tclsh 9.0.4 | tclrs interp | tclrs JIT | tclrs AOT |
| --- | ---: | ---: | ---: | ---: |
| `startup` | 13.6 ± 0.7 | 5.1 ± 0.4 | 5.4 ± 0.5 | 3.2 ± 0.3 |
| `counted_loop_proc` | 56.2 ± 3.1 | 192.1 ± 3.7 | 6.5 ± 1.4 | 4.5 ± 0.1 |
| `counted_loop` | 407.4 ± 9.6 | 181.7 ± 3.9 | 6.4 ± 0.4 | 4.9 ± 0.3 |
| `counted_loop_expr` | 480.5 ± 10.0 | 188.0 ± 7.8 | 7.4 ± 0.3 | 5.3 ± 0.3 |
| `integer_arith` | 298.7 ± 5.5 | 149.0 ± 2.9 | 7.0 ± 0.4 | 4.7 ± 0.4 |
| `string_build` | 823.8 ± 134.4 | 18.3 ± 0.6 | 20.7 ± 0.9 | 19.1 ± 0.5 |
| `list_iterate` | 15.1 ± 1.8 | 7.5 ± 0.5 | 7.7 ± 1.4 | 5.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`:

| | tclsh | JIT | AOT | JIT vs tclsh |
| --- | ---: | ---: | ---: | ---: |
| `counted_loop` | 386.6 | 5.8 | 4.3 | **67×** |
| `counted_loop_expr` | 461.9 | 7.0 | 4.7 | **66×** |
| `integer_arith` | 284.7 | 6.4 | 4.2 | **44×** |

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](#0x08-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` was the one row tclrs lost,
by 14×: the list lived in the variable as its string representation and 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 `source`d 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`](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:

```sh
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`](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.

```sh
tk-conformance/run.sh
```

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

---

## [0x0C] TESTING

```sh
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`](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`](tests/lsp_session.rs) and
[`tests/dap_session.rs`](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`](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/`](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:

```sh
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`](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

```sh
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 refused** — `array` 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`.

```sh
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`](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.

| target     | surface                                                    |
| ---------- | ---------------------------------------------------------- |
| `parse`    | the command language, on arbitrary bytes                   |
| `compiler` | parse and lowering, without running anything               |
| `expr`     | the expression grammar, which `parse` never reaches        |
| `eval`     | a generated script, compiled and run                       |
| `vm`       | one chunk run twice, on two interpreters                   |

```sh
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`](LICENSE).