tclrs 0.4.9

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
//! `proc`, `return` and `global` — procedures and their scope.
//!
//! ## How a Tcl procedure maps onto fusevm's calling convention
//!
//! A procedure's body is compiled into the enclosing chunk's op stream, behind
//! a `Op::Jump` that steps over it, and registered with
//! `ChunkBuilder::add_sub_entry(name_idx, entry_ip)`. `Op::Call(name_idx, n)`
//! resolves that entry through `Chunk::find_sub`, pushes a `Frame` whose
//! `stack_base` is `stack.len() - n`, and jumps to it; the `n` argument values
//! the call site pushed are therefore sitting at the base of the new frame.
//! The prologue moves them into the frame's slots with `Op::SetSlot(n-1)` down
//! to `Op::SetSlot(0)` — reverse order, since the last argument is on top.
//! `Op::ReturnValue` pops the frame, truncates the stack back to `stack_base`
//! (which discards anything the body left behind) and pushes the result, so a
//! call is a net `1 - n` on the caller's stack depth.
//!
//! Frame slots are what give a procedure its own variables: fusevm allocates a
//! fresh `Vec` of them per call, so locals neither leak into the globals nor
//! collide with an outer activation of the same recursive procedure. Only
//! names declared with `global` bypass the slots, compiling to
//! `Op::GetVar`/`Op::SetVar`, which address the VM's global table directly.
//!
//! Because the count of formal parameters is fixed but a call may pass fewer,
//! the *call site* does the adapting: it pushes a constant for each defaulted
//! parameter the caller omitted and folds any surplus arguments into the
//! variadic `args` list. The callee then always receives exactly one value per
//! formal parameter, which is what makes the fixed prologue above correct.
//!
//! ## A `proc` that is not at the script's top level
//!
//! Everything above needs the callee's name and signature while the *caller* is
//! being compiled. A `proc` inside an `if`, a loop or another procedure's body
//! does not supply them: in tclsh the command starts answering when the
//! defining code runs, and not before. Measured against tclsh 9.0.4:
//!
//! ```text
//! if {0} {proc f {} {}} ; f       → invalid command name "f"
//! if {1} {proc f {} {}} ; f       → runs
//! proc f {} {return one}
//! if {1} {proc f {} {return two}} ; f   → two
//! if {1} {proc f {a b} {}} ; f 1   → wrong # args: should be "f a b"
//! proc outer {} {proc inner {} {…}}     → `inner` exists only after `outer` ran
//! ```
//!
//! So the body is compiled where it stands — behind a jump, with the same
//! prologue, reaching the same tiers — and the *name* is bound separately, by
//! [`ext::PROC_DEFINE`], when control reaches the `proc` command. A call to a
//! name bound that way is [`ext::DYN_CALL`], which resolves in the run-time
//! command table and does the argument adapting the compiler would have done.
//!
//! Compile-time resolution is untouched by any of this. A `proc` at the
//! script's top level still registers a `Chunk` sub-entry, its calls are still
//! `Op::Call(name_idx, n)` with the actuals arranged by the call site, and
//! `bench/counted_loop_proc.tcl` still trace-compiles. The run-time path is
//! reached only for a name whose definition is conditional, which the compiler
//! learns in its first pass (see [`crate::compiler::compile`]).

use std::collections::HashMap;
use std::sync::Arc;

use fusevm::{Frame, Op, Value, VM};

use crate::compiler::{ext, literal_value, CompileError, Compiler, Scope};
use crate::list;
use crate::parser::{Script, Word};
use crate::runtime::{to_tcl_string, Shared, TclError};

/// One formal parameter of a procedure.
#[derive(Clone, PartialEq, Eq)]
pub struct Param {
    pub name: String,
    /// The value used when the caller omits this argument.
    pub default: Option<String>,
}

/// A procedure's formal argument list.
#[derive(Clone, PartialEq, Eq)]
pub struct Signature {
    /// Every formal parameter, including the trailing `args` when `variadic`.
    pub params: Vec<Param>,
    /// The last formal is named `args`, so surplus actuals collect into it.
    pub variadic: bool,
    /// The fewest actual arguments the procedure accepts. `proc(n)`: a
    /// defaulted parameter followed by a non-defaulted one is required all the
    /// same, so this is one past the last parameter without a default.
    pub required: usize,
    /// The body's source text, for `info body`, or `None` when the definition
    /// computed it (`proc p {} $b`) — there is no text then, only a value the
    /// run produced.
    ///
    /// Kept on the signature rather than in a table of its own because the two
    /// are collected in the same walk and published through the same
    /// `crate::runtime::note_procs`: one record per procedure, so a name
    /// cannot have a signature and no body by accident.
    pub body: Option<String>,
}

impl Signature {
    /// Formal parameters that take one actual argument each — everything but
    /// the trailing `args`.
    pub fn fixed(&self) -> usize {
        self.params.len() - usize::from(self.variadic)
    }

    /// The usage line Tcl reports for a call with the wrong argument count.
    pub fn usage(&self, name: &str) -> String {
        let mut out = name.to_string();
        for (i, p) in self.params.iter().enumerate() {
            out.push(' ');
            if self.variadic && i + 1 == self.params.len() {
                out.push_str("?arg ...?");
            } else if p.default.is_some() {
                out.push('?');
                out.push_str(&p.name);
                out.push('?');
            } else {
                out.push_str(&p.name);
            }
        }
        out
    }
}

/// Parse a `proc` argument specifier: a list whose elements are either a
/// parameter name or a two-element `{name default}` list.
pub fn parse_signature(proc_name: &str, spec: &str) -> Result<Signature, String> {
    let mut params: Vec<Param> = Vec::new();
    for element in list::split(spec)? {
        let fields = list::split(&element)?;
        let param = match fields.as_slice() {
            [name] => Param {
                name: name.clone(),
                default: None,
            },
            [name, default] => Param {
                name: name.clone(),
                default: Some(default.clone()),
            },
            [] => return Err("argument with no name".to_string()),
            _ => {
                return Err(format!(
                    "too many fields in argument specifier \"{element}\""
                ))
            }
        };
        if param.name.is_empty() {
            return Err("argument with no name".to_string());
        }
        if param.name.ends_with(')') && param.name.contains('(') {
            return Err(format!(
                "formal parameter \"{}\" is an array element",
                param.name
            ));
        }
        if params.iter().any(|p| p.name == param.name) {
            return Err(format!(
                "procedure \"{proc_name}\" has argument \"{}\" defined twice",
                param.name
            ));
        }
        params.push(param);
    }

    let variadic = params.last().is_some_and(|p| p.name == "args");
    let fixed = params.len() - usize::from(variadic);
    let required = params[..fixed]
        .iter()
        .rposition(|p| p.default.is_none())
        .map_or(0, |i| i + 1);

    Ok(Signature {
        params,
        variadic,
        required,
        // The spec is all this function is given; the body is attached by
        // whoever has it — [`prescan`] and `Compiler::cmd_proc`.
        body: None,
    })
}

/// Collect the signature of every procedure the script's own commands define,
/// before any of them is compiled. A procedure body may then call one defined
/// further down — which Tcl allows, since the name is only looked up when the
/// call runs. Malformed definitions are skipped here and reported when the
/// `proc` command itself is compiled.
pub fn prescan(procs: &mut HashMap<String, Signature>, script: &Script) {
    for cmd in &script.commands {
        let [head, name, spec, body] = cmd.words.as_slice() else {
            continue;
        };
        if head.as_literal() != Some("proc") {
            continue;
        }
        let (Some(name), Some(spec)) = (name.as_literal(), spec.as_literal()) else {
            continue;
        };
        if let Ok(mut sig) = parse_signature(name, spec) {
            // The body's source text, which `info body` answers with. A computed
            // body has none, and `info body` on such a procedure says what it
            // says for a name that is no procedure — see
            // [`crate::cmd_info::ext::BODY`].
            sig.body = body.as_literal().map(str::to_string);
            procs.insert(name.to_string(), sig);
        }
    }
}

// ── the run-time command table ───────────────────────────────────────────

/// A procedure whose name was bound while the script was running.
///
/// The entry point is an op index, which only means anything inside the chunk it
/// was taken from — so the chunk itself is recorded with it, as a handle that can
/// still be *run*. A nested `eval` runs a chunk of its own; a procedure defined
/// in one has an entry point that indexes nothing in the other, and jumping to it
/// would run whichever op happens to sit at that index. Holding the chunk is what
/// turns that from a miss into a call: see [`enter_elsewhere`].
///
/// `Chunk::op_hash` is `#[serde(skip)]`, so it is 0 for every chunk an
/// ahead-of-time binary deserialized: [`chunk_key`] keeps the op count alongside
/// it so that the "is this the running chunk?" test is not resting on one field
/// that can be zero.
#[derive(Clone)]
pub(crate) struct RuntimeProc {
    chunk: Arc<fusevm::Chunk>,
    entry: usize,
    sig: Signature,
}

type ChunkKey = (u64, usize);

fn chunk_key(chunk: &fusevm::Chunk) -> ChunkKey {
    (chunk.op_hash, chunk.ops.len())
}

/// The name a procedure is filed under: the qualified name with no leading
/// `::`, which is how [`crate::cmd_namespace::store_key`] spells one everywhere
/// else in this crate.
///
/// A definition writes `proc ::tk::ScreenChanged` and a caller writes
/// `tk::ScreenChanged` or `::tk::ScreenChanged`; all three name one procedure, so
/// all three have to reach one key.
fn table_key(name: &str) -> &str {
    crate::cmd_namespace::store_key(name)
}

/// [`ext::PROC_DEFINE`]: bind `name` to the body at `entry`, as the `proc`
/// command that is running says to.
///
/// The argument list arrives as the text the script wrote, and is parsed here
/// rather than carried as a structure, because this is the one place a
/// signature is needed at run time and the text is already a chunk constant.
/// It parsed once at compile time, which is where a malformed one is reported;
/// the refusal is repeated rather than assumed away.
pub(crate) fn define_op(interp: &Shared, vm: &mut VM) -> Result<(), TclError> {
    let entry = vm.pop();
    let spec = to_tcl_string(&vm.pop());
    let name = to_tcl_string(&vm.pop());
    let entry = match entry {
        Value::Int(n) if n >= 0 && (n as usize) < vm.chunk.ops.len() => n as usize,
        other => {
            return Err(TclError::plain(format!(
                "procedure \"{name}\" has no body at {}",
                to_tcl_string(&other)
            )))
        }
    };
    let sig = parse_signature(&name, &spec).map_err(TclError::plain)?;
    let mut state = interp.lock().expect("interpreter lock");
    let chunk = running_chunk(&state, vm);
    let defined = RuntimeProc { chunk, entry, sig };
    state.commands.insert(table_key(&name).to_string(), defined);
    drop(state);
    // `proc` itself evaluates to the empty string.
    vm.push(Value::Str(std::sync::Arc::new(String::new())));
    Ok(())
}

/// A handle to the chunk the calling VM is running.
///
/// The interpreter records it on the way in ([`crate::runtime::State::running`]),
/// so the ordinary answer is a handle to the same allocation and costs a
/// reference count. The copy below is the fallback for a VM whose chunk the
/// interpreter never saw — nothing in this crate produces one, and a wrong
/// entry point would run arbitrary ops, so the identity is checked rather than
/// assumed.
fn running_chunk(state: &crate::runtime::State, vm: &VM) -> Arc<fusevm::Chunk> {
    match state.running.last() {
        Some(chunk) if chunk_key(chunk) == chunk_key(&vm.chunk) => Arc::clone(chunk),
        _ => Arc::new(vm.chunk.clone()),
    }
}

/// [`ext::DYN_CALL`]: the operands are the script line, the command name and
/// then the arguments, in the order the compiler pushed them.
///
/// The line rides on the stack because the failures this op can produce are the
/// ones the compiler used to decide, and those are *located* — dropping the line
/// would turn `(file "x.tcl" line 7)` into a message with no place, which a
/// differential test against tclsh would notice.
pub(crate) fn call_op(interp: &Shared, vm: &mut VM, argc: u8) -> Result<(), TclError> {
    let mut values = Vec::with_capacity(argc as usize);
    for _ in 0..argc {
        values.push(vm.pop());
    }
    values.reverse();
    let line = match values.first() {
        Some(Value::Int(n)) => *n as usize,
        _ => 0,
    };
    let name = to_tcl_string(&values[1]);
    invoke(interp, vm, &name, &values[2..], line)
}

/// Call the command `name` with `args`, resolving it in the run-time table and
/// then outside it — the resolution both run-time call ops share.
///
/// `line` is the call site's, for the failures that would otherwise have no
/// place: `invalid command name`, `wrong # args`. A failure raised *inside* a
/// procedure's body carries its own and keeps it.
fn invoke(
    interp: &Shared,
    vm: &mut VM,
    name: &str,
    args: &[Value],
    line: usize,
) -> Result<(), TclError> {
    let defined = defined_proc(interp, name);
    dispatch(interp, vm, name, args, line, defined)
}

/// What the interpreter's run-time command table holds for `name`.
///
/// The lock is taken and released here rather than held across the call, because
/// entering the body runs arbitrary Tcl — which may define another procedure or
/// `eval` a script, and both of those want this same lock.
fn defined_proc(interp: &Shared, name: &str) -> Option<RuntimeProc> {
    let state = interp.lock().expect("interpreter lock");
    state.commands.get(table_key(name)).cloned()
}

/// The line a failure raised by a call op should report as `(file "…" line N)`,
/// or `None` for "no file location".
///
/// tclsh's `(file …)` names the TOP-LEVEL command that was running. Inside a
/// procedure that is the CALL, and the line a call op carries there is the
/// body's own — a three-line procedure called from line 10 reported `line 3`,
/// where tclsh reports `(procedure "p" line 3)` for that position and
/// `(file … line 10)` for the file. Printing the body's line under a `(file …)`
/// label is worse than printing none, and printing none is what every other
/// run-time failure inside a procedure already does here.
///
/// Frame 0 is the chunk's own top level (see `crate::cmd_scope`), so anything
/// above it is a procedure body.
fn located(vm: &VM, line: usize) -> Option<usize> {
    (vm.frames.len() <= 1).then_some(line)
}

/// The half of [`invoke`] after the lookup, so that a caller which has already
/// looked the name up — [`expand_call_op`], which needs to know whether it missed
/// before it decides what else the name could be — does not look it up twice.
fn dispatch(
    interp: &Shared,
    vm: &mut VM,
    name: &str,
    args: &[Value],
    line: usize,
    defined: Option<RuntimeProc>,
) -> Result<(), TclError> {
    let at = located(vm, line);
    let here = move |msg: String| TclError {
        msg,
        line: at,
        code: crate::runtime::TCL_ERROR,
        level: 0,
        errorcode: None,
    };
    match defined {
        // A procedure the script defined shadows a foreign command of the same
        // name, which is the order tclsh resolves in.
        Some(p) if chunk_key(&p.chunk) == chunk_key(&vm.chunk) => {
            enter(vm, name, &p, args).map_err(here)
        }
        // The same procedure, reached from a chunk its body is not in.
        Some(p) => enter_elsewhere(interp, vm, name, &p, args).map_err(|e| match e.line {
            Some(_) => e,
            None => here(e.msg),
        }),
        // A registered Tk command is the only thing a chunk hands control to
        // that can read or write the interpreter's variables behind its back,
        // so this is where the running slot vector and the interpreter's map
        // are brought into agreement and the traces that costs are fired. The
        // sync sits on *this* arm rather than around the whole op because the
        // arm above it is an ordinary procedure call, which cannot do that and
        // should not pay for it. See `crate::runtime::sync_out`; when nothing
        // is traced each side is one atomic load.
        None => foreign(interp, vm, name, args).map_err(here),
    }
}

/// Enter a procedure's body, having arranged the actual arguments the way its
/// prologue expects them.
///
/// This is [`Compiler::push_actuals`] at run time and it makes the same three
/// decisions: refuse an argument count the signature does not admit, supply a
/// default for each omitted parameter, and fold the surplus into `args`. The
/// frame is the one `Op::Call` would have pushed — `stack_base` beneath the
/// formals, so `Op::ReturnValue` truncates back past them — and `vm.ip` is
/// already the op after this one, which is where the body returns to.
fn enter(vm: &mut VM, name: &str, p: &RuntimeProc, args: &[Value]) -> Result<(), String> {
    let actuals = actuals(name, &p.sig, args)?;
    let base = vm.stack.len();
    for value in actuals {
        vm.push(value);
    }
    vm.frames.push(Frame {
        return_ip: vm.ip,
        stack_base: base,
        slots: Vec::new(),
        // fusevm 0.17.0: a frame carries the entry point of the subroutine it is
        // running, and `Op::Call` fills it in. This call is dispatched by
        // [`ext::DYN_CALL`] instead — a procedure whose name only a run-time
        // table resolves — so it fills it in itself. Leaving it `None` would make
        // the activation invisible to `VM::slot_names_at` and to
        // `crate::runtime::levels`, so `info level`, `uplevel` and `upvar` would
        // all count one level too few inside such a procedure.
        entry_ip: Some(p.entry),
    });
    vm.ip = p.entry;
    Ok(())
}

/// One value per formal parameter, which is what a procedure's fixed prologue
/// expects: refuse an argument count the signature does not admit, supply a
/// default for each omitted parameter, and fold the surplus into `args`.
///
/// [`Compiler::push_actuals`] makes the same three decisions while compiling, for
/// a call whose callee the compiler knows. This is that function for a callee only
/// the run-time table knows, and it is one function rather than one per entry path
/// so that a jump into the body and a run of the body in another chunk cannot
/// disagree about what the arguments are.
fn actuals(name: &str, sig: &Signature, args: &[Value]) -> Result<Vec<Value>, String> {
    let fixed = sig.fixed();
    if args.len() < sig.required || (!sig.variadic && args.len() > fixed) {
        return Err(format!("wrong # args: should be \"{}\"", sig.usage(name)));
    }
    let mut out = Vec::with_capacity(sig.params.len());
    for i in 0..fixed {
        out.push(match args.get(i) {
            Some(v) => v.clone(),
            // `required` guarantees the omitted parameters have defaults.
            None => {
                let default = sig.params[i]
                    .default
                    .as_deref()
                    .expect("defaulted parameter");
                literal_value(default)
            }
        });
    }
    if sig.variadic {
        let rest: Vec<String> = args[fixed.min(args.len())..]
            .iter()
            .map(to_tcl_string)
            .collect();
        out.push(Value::Str(Arc::new(list::join(&rest))));
    }
    Ok(out)
}

/// Call a procedure whose body was compiled into another chunk.
///
/// An entry point is an op index, so this cannot be a jump: the body runs on a
/// VM of its own over the chunk it belongs to
/// ([`crate::runtime::call_in_chunk`]), and the value it returns is pushed here
/// as the call's result. The variables are the interpreter's either way — the
/// chunk being left writes its slot vector back before the nested run and re-reads
/// it after, which is the exchange `eval` already makes — so the two runs are one
/// interpreter and one set of globals.
///
/// This is what makes a procedure callable from anywhere in an interpreter rather
/// than only from the chunk that defined it: `source`, `eval`, an `after` script
/// and a Tk binding script are each a chunk of their own, and in tclsh a
/// procedure is visible from all of them.
fn enter_elsewhere(
    interp: &Shared,
    vm: &mut VM,
    name: &str,
    p: &RuntimeProc,
    args: &[Value],
) -> Result<(), TclError> {
    let actuals = actuals(name, &p.sig, args).map_err(TclError::plain)?;
    let chunk = Arc::clone(&p.chunk);
    let entry = p.entry;
    let value = crate::runtime::with_written_back(interp, vm, |interp| {
        crate::runtime::call_in_chunk(interp, &chunk, entry, actuals)
    })?;
    vm.push(value);
    Ok(())
}

/// A name no procedure answers to: a command Tk registered, or nothing.
///
/// Without the `tk` feature there is no second table to consult, and the answer
/// is the `invalid command name` the compiler used to defer — same wording,
/// same line.
fn foreign(interp: &Shared, vm: &mut VM, name: &str, args: &[Value]) -> Result<(), String> {
    #[cfg(feature = "tk")]
    {
        // Two exchanges, and they are not the same one.
        //
        // `sync_out` fires the write traces a Tk widget's `-variable` may have
        // on it, and `sync_in` re-empties the read-traced slots so the next
        // read fires. Both are one atomic load when nothing is traced.
        //
        // `flush_globals`/`reseed_globals` move *every* variable, because the
        // command being called may re-enter the interpreter — a `-command`
        // callback, a `bind` script and an `after` script are all evaluated
        // from inside Tk while this call is on the stack. Without them a
        // callback cannot see a variable the script set, and what the callback
        // sets never reaches the script.
        crate::runtime::sync_out(interp, vm)?;
        crate::runtime::flush_globals(vm, interp);
        let outcome = crate::tk::dispatch::invoke(name, args);
        // Even a command that failed may have written a variable before it
        // failed, exactly as a failing script's `set` still counts, so taking
        // the interpreter's values back up is not on the success path only.
        crate::runtime::reseed_globals(vm, interp);
        crate::runtime::sync_in(interp, vm);
        vm.push(Value::Str(std::sync::Arc::new(outcome?)));
        Ok(())
    }
    #[cfg(not(feature = "tk"))]
    {
        let _ = (interp, vm, args);
        Err(format!("invalid command name \"{name}\""))
    }
}

/// [`ext::EXPAND_CALL`]: the operands are the script line and then one flag and
/// one value per word of the command, in the order the compiler pushed them.
///
/// The words become an argument vector — a flagged one contributing its list
/// elements, an unflagged one contributing itself — and the vector's first
/// element is the command's name. Which command that is decides how it runs:
///
/// * a procedure, from the run-time table, entered exactly as [`call_op`] enters
///   one;
/// * one of this frontend's own commands, which is compiled — the arguments are
///   values by now, so the command is rebuilt as a *list* and evaluated, and a
///   list evaluated as a script is one command whose words are its elements with
///   no further substitution. That is what `eval [list …]` means in Tcl, and it
///   is why `set {*}{a b}` assigns rather than being refused for an argument
///   count no compiler could have known;
/// * anything else — a command Tk registered, or nothing at all — through
///   [`foreign`], which answers `invalid command name` when it is nothing.
///
/// A command whose words all expand to nothing is not an error: `{*}{}` alone
/// runs no command and answers the empty string in tclsh 9.0.4 (measured, and
/// `catch {{*}{}}` there is 0). That is `INST_INVOKE_EXPANDED`'s own arm —
/// "Nothing was expanded, return {}", `generic/tclExecute.c:2740-2750`.
///
/// The splice refuses a word that is not a well-formed list, as
/// `INST_EXPAND_STKTOP` does with `TclListObjGetElements`
/// (`generic/tclExecute.c:2645-2656`: "Make sure that the element at stackTop is
/// a list; if not, just leave with an error"), which is where `unmatched open
/// quote in list` comes from.
pub(crate) fn expand_call_op(interp: &Shared, vm: &mut VM, argc: u8) -> Result<(), TclError> {
    let mut values = Vec::with_capacity(argc as usize);
    for _ in 0..argc {
        values.push(vm.pop());
    }
    values.reverse();
    let line = match values.first() {
        Some(Value::Int(n)) => *n as usize,
        _ => 0,
    };
    let at = located(vm, line);
    let here = move |msg: String| TclError {
        msg,
        line: at,
        code: crate::runtime::TCL_ERROR,
        level: 0,
        errorcode: None,
    };
    let words = splice(&values[1..]).map_err(here)?;
    let Some((first, args)) = words.split_first() else {
        vm.push(Value::Str(Arc::new(String::new())));
        return Ok(());
    };
    let name = to_tcl_string(first);
    // A procedure of this interpreter wins over the compiled command of the same
    // name, which cannot happen — `proc` refuses a built-in name — but the order
    // is the one tclsh resolves in, and one lookup answers both questions: which
    // procedure to enter, or whether to fall through to the compiler.
    let defined = defined_proc(interp, &name);
    if defined.is_none() && crate::names::is_command(&name) {
        return as_script(interp, vm, &words).map_err(|e| here(e.msg));
    }
    dispatch(interp, vm, &name, args, line, defined)
}

/// Splice the `(flag, value)` pairs [`ext::EXPAND_CALL`] carries into one
/// argument vector.
///
/// A flagged word's value is parsed as a Tcl list and its elements are spliced in
/// place of it, which is rule 5's definition of `{*}`; an unflagged one is passed
/// through as the value it is, so a number the VM computed stays a number.
fn splice(pairs: &[Value]) -> Result<Vec<Value>, String> {
    let mut out = Vec::with_capacity(pairs.len() / 2);
    for pair in pairs.chunks(2) {
        let [flag, value] = pair else {
            // The compiler emits the pairs; an odd count is a corrupt chunk.
            return Err("malformed expanded call".to_string());
        };
        if matches!(flag, Value::Int(0)) {
            out.push(value.clone());
            continue;
        }
        for element in list::split(&to_tcl_string(value))? {
            out.push(Value::Str(Arc::new(element)));
        }
    }
    Ok(out)
}

/// Run the command `words` spells by evaluating it: the words as a *list*, which
/// as a script is one command with those words and no substitution left to do.
///
/// The one path a compiled command can be reached by when its argument count was
/// not known while the script was read. It costs a compilation of the rebuilt
/// command — [`crate::cache`] keeps it, so a call repeated with the same values
/// compiles once — which is the price of not having a second implementation of
/// every command that takes an argument vector.
///
/// The nested script cannot see a procedure's *local* variables, since a chunk
/// addresses locals as frame slots of its own; every word here is already a value,
/// so the only case that reaches the difference is an expanded command that
/// assigns — `set {*}{a b}` inside a procedure body writes the global `a`.
/// BUGS.md records it.
fn as_script(interp: &Shared, vm: &mut VM, words: &[Value]) -> Result<(), TclError> {
    let text: Vec<String> = words.iter().map(to_tcl_string).collect();
    let src = list::join(&text);
    let value = crate::runtime::with_written_back(interp, vm, |interp| {
        crate::runtime::run_source(interp, &src)
    })?;
    vm.push(value);
    Ok(())
}

impl Compiler {
    /// `proc name args body`.
    pub(crate) fn cmd_proc(&mut self, args: &[Word]) -> Result<(), CompileError> {
        let [name_w, spec_w, body_w] = args else {
            return self.error("wrong # args: should be \"proc name args body\"");
        };
        // A `proc` the script's own text runs exactly once binds its name while
        // this compiler is running, so its calls can be `Op::Call`. Anywhere
        // else the binding is an event at run time, and both halves of that —
        // the definition and every call — go through the run-time table.
        let at_top = self.top_level;
        let name = self.literal_of(name_w, "procedure name")?.to_string();
        if Compiler::BUILTINS.contains(&name.as_str()) {
            return self.error(format!(
                "redefining the built-in command \"{name}\" is not supported"
            ));
        }
        if self.coros.contains(&name) {
            return self.error(format!(
                "procedure \"{name}\" collides with a coroutine of the same name, which is \
                 not supported"
            ));
        }
        let spec = self.literal_of(spec_w, "argument list")?.to_string();
        let mut sig = match parse_signature(&name, &spec) {
            Ok(sig) => sig,
            Err(msg) => return self.error(msg),
        };
        // The body's source text, which `info body` answers with. [`prescan`]
        // recorded it already for a definition among the script's own commands,
        // and this record replaces that one — so it has to carry the text too,
        // or `info body` on a procedure defined at the top level would find a
        // signature with no body.
        sig.body = body_w.as_literal().map(str::to_string);
        if !at_top {
            // Recorded for the next pass, which is what turns every call to
            // this name — including the ones already compiled above it — into a
            // run-time lookup. A definition in a branch that is never taken is
            // recorded too: the name being *conditional* is the fact that
            // matters, not whether the condition holds.
            self.seen_runtime.insert(name.clone());
        } else if !self.defined.insert(name.clone()) {
            // Only a top-level definition claims the name at compile time, so
            // only a second top-level one is a redefinition this compiler
            // cannot represent. A conditional one is not: it replaces the
            // command when it runs, which the run-time table does model.
            return self.error(format!(
                "procedure \"{name}\" is redefined, which is not supported"
            ));
        }
        let slots = u8::try_from(sig.params.len())
            .map_err(|_| {
                self.err(format!(
                    "procedure \"{name}\" has more than 255 formal parameters"
                ))
            })?
            .into();
        if at_top {
            // The signature a call site needs in order to arrange the actuals
            // itself. A conditional definition supplies it at run time instead,
            // out of the argument-list text [`ext::PROC_DEFINE`] carries.
            self.procs.insert(name.clone(), sig.clone());
        }
        // A body that will not parse is still a definition: tclsh compiles a
        // procedure's body when it is first called, so `proc p {} {puts "x}`
        // with `p` never called runs to completion there. The failure becomes
        // the body's only instruction, which is where calling it finds it.
        let body = self.body_of(body_w)?;

        let skip = self.emit(Op::Jump(usize::MAX), 0);
        let entry = self.b.current_pos();

        // The body compiles in its own frame: a fresh slot scope, no enclosing
        // loop to break out of, and no enclosing `catch`.
        let outer_depth = std::mem::replace(&mut self.depth, slots);
        let outer_loops = std::mem::take(&mut self.loops);
        let outer_catch = std::mem::replace(&mut self.catch_depth, 0);
        let outer_scope = self.scope.replace(scope_for(&sig));
        let outer_top = std::mem::replace(&mut self.top_level, false);
        let outer_static = std::mem::replace(&mut self.static_ctx, false);

        for slot in (0..slots).rev() {
            self.emit(Op::SetSlot(slot as u16), -1);
        }
        let compiled = match &body {
            crate::compiler::Body::Script(script) => self.script_value(script),
            crate::compiler::Body::Deferred(msg) => {
                let msg = msg.clone();
                self.raise_at_run_time(&msg)
            }
        };
        // A body that falls off its end returns the value of its last command.
        self.emit(Op::ReturnValue, -1);

        self.depth = outer_depth;
        self.loops = outer_loops;
        self.catch_depth = outer_catch;
        // The body's scope is taken rather than dropped: its slot names are what
        // lets a nested script reach this procedure's variables at run time —
        // `eval` in a body, `uplevel`, `upvar` — and they are only known once the
        // whole body has been lowered, since a local is allocated a slot the
        // first time the body mentions it.
        //
        // The names are recorded twice, into two tables that are read by
        // different questions. `ChunkBuilder::set_sub_slot_names` (fusevm 0.17.0)
        // puts them in the chunk, keyed by the body's entry point, which is what
        // `VM::slot_names_at` answers from — a frame the VM entered through
        // `Op::Call`. [`Compiler::publish_slot_names`] puts them in a table keyed
        // by the body's *op range*, which is what attributes a frame to a body
        // when the question starts from the instruction pointer instead: `upvar`
        // has an absolute Tcl level and no `Op::Call` of its own to read an entry
        // point from. See [`crate::cmd_scope`].
        let body_scope = std::mem::replace(&mut self.scope, outer_scope);
        self.top_level = outer_top;
        self.static_ctx = outer_static;
        compiled?;

        let after = self.b.current_pos();
        self.b.patch_jump(skip, after);
        if let Some(scope) = body_scope {
            self.b.set_sub_slot_names(entry, slot_names_of(&scope));
            self.publish_slot_names(&scope, entry, after);
        }
        if at_top {
            // The address book `Op::Call` and `coroutine` resolve through. Only
            // a top-level definition earns one: two conditional definitions may
            // share a name, and `Chunk::find_sub` answers with the first entry
            // registered under it, which would send both calls to one body.
            let name_idx = self.b.add_name(&name);
            self.b.add_sub_entry(name_idx, entry);
        }
        // Every definition binds its name in the run-time table as well, because
        // the chunk's own address book answers only inside the chunk: a `source`d
        // file, an `eval`, an `after` script and a Tk binding script are each a
        // chunk of their own, and in tclsh a procedure defined at one script's top
        // level is callable from all of them. Four ops per definition, run once —
        // a call the compiler *can* resolve is still `Op::Call`, so nothing on a
        // call path pays for this.
        self.push_str(&name);
        self.push_str(&spec);
        self.push_value(Value::Int(entry as i64));
        // Three operands off the stack for `proc`'s own empty result on.
        self.emit(Op::Extended(ext::PROC_DEFINE, 3), -2);
        Ok(())
    }

    /// A call to a command whose name is resolved when the call runs: a
    /// procedure some conditional `proc` defines, or a command Tk registered.
    ///
    /// The line, the name, then the arguments, then the op that pops all of
    /// them. The arguments are lowered exactly as any other command's are, so a
    /// command substitution inside one still runs before the dispatch — and
    /// still runs even when the dispatch then fails, which is the order Tcl
    /// substitutes in and the order [`Compiler::defer`] already preserved.
    pub(crate) fn call_runtime(&mut self, name: &str, args: &[Word]) -> Result<(), CompileError> {
        let count = u8::try_from(args.len() + 2)
            .map_err(|_| self.err(format!("more than 253 arguments to the command \"{name}\"")))?;
        self.push_value(Value::Int(self.command_line as i64));
        self.push_str(name);
        for arg in args {
            self.word(arg)?;
        }
        // The op consumes the line, the name and every argument, and leaves the
        // command's value: `count` off the stack for one on. The two operands
        // the compiler synthesised are part of that count, so the depth this
        // reports is one deeper than a call with the name alone would be.
        self.emit(Op::Extended(ext::DYN_CALL, count), 1 - count as i32);
        Ok(())
    }

    /// A command with at least one `{*}` word (rule 5 of the dodekalogue).
    ///
    /// The whole command is handed to run time: the line, then every word as a
    /// flag and a value, then [`ext::EXPAND_CALL`], which splices the flagged
    /// ones and calls whatever the result spells. Nothing is resolved here
    /// because nothing can be — `{*}$list` decides the argument count when the
    /// list is read, and `{*}$cmd` decides the command's *name* the same way.
    ///
    /// The words are lowered in the order they were written, so a command
    /// substitution in one still runs before the dispatch and still runs when
    /// the dispatch then fails: `n [puts before] {*}{x "y}` prints `before` and
    /// then reports `unmatched open quote in list` in tclsh 9.0.4 (measured).
    pub(crate) fn call_expanded(&mut self, words: &[Word]) -> Result<(), CompileError> {
        let count = u8::try_from(1 + 2 * words.len()).map_err(|_| {
            self.err("more than 126 words in a command with {*} argument expansion".to_string())
        })?;
        self.push_value(Value::Int(self.command_line as i64));
        for w in words {
            self.emit(Op::LoadInt(i64::from(w.expand)), 1);
            self.word_value(w)?;
        }
        self.emit(Op::Extended(ext::EXPAND_CALL, count), 1 - count as i32);
        Ok(())
    }

    /// A call to a procedure this script defines.
    pub(crate) fn call_proc(&mut self, name: &str, args: &[Word]) -> Result<(), CompileError> {
        let slots = self.push_actuals(name, args)?;
        let name_idx = self.b.add_name(name);
        self.emit(Op::Call(name_idx, slots as u8), 1 - slots as i32);
        Ok(())
    }

    /// Push exactly one value per formal parameter of the procedure `name`,
    /// which is what its fixed prologue expects, and answer how many. This is
    /// where a call adapts to the signature: an omitted parameter's default is
    /// pushed here, and surplus arguments are collected into `args` here.
    ///
    /// `coroutine` uses it too — the body of a coroutine is entered with the
    /// same convention as a call, only from a fresh VM the driver positions.
    pub(crate) fn push_actuals(
        &mut self,
        name: &str,
        args: &[Word],
    ) -> Result<usize, CompileError> {
        let sig = self.procs.get(name).cloned().expect("known procedure");
        let fixed = sig.fixed();
        if args.len() < sig.required || (!sig.variadic && args.len() > fixed) {
            return self.error(format!("wrong # args: should be \"{}\"", sig.usage(name)));
        }

        for i in 0..fixed {
            match args.get(i) {
                Some(w) => self.word(w)?,
                // `required` guarantees the omitted parameters have defaults.
                None => {
                    let default = sig.params[i].default.clone().expect("defaulted parameter");
                    self.push_text(&default);
                }
            }
        }
        if sig.variadic {
            let extra = &args[fixed.min(args.len())..];
            let count = u8::try_from(extra.len()).map_err(|_| {
                self.err(format!(
                    "more than 255 arguments collected into \"args\" of \"{name}\""
                ))
            })?;
            for w in extra {
                self.word(w)?;
            }
            self.emit(Op::Extended(ext::LIST, count), 1 - extra.len() as i32);
        }
        Ok(sig.params.len())
    }

    /// `return ?-code code? ?result?`.
    pub(crate) fn cmd_return(&mut self, args: &[Word]) -> Result<(), CompileError> {
        // `-code` and `-level` may be written in either order and any number of
        // times, the last of each winning, which is how `Tcl_ReturnObjCmd`
        // reads them.
        let mut rest = args;
        let mut code = crate::runtime::TCL_OK;
        let mut level = 1;
        // `-errorcode`, kept as the WORD so a computed one
        // (`return -errorcode $c`) is evaluated where it is written.
        let mut errorcode: Option<&Word> = None;
        while let [first, value, tail @ ..] = rest {
            match first.as_literal() {
                Some("-code") => {
                    let text = self.literal_of(value, "return code")?.to_string();
                    code = match text.as_str() {
                        "ok" => crate::runtime::TCL_OK,
                        "error" => crate::runtime::TCL_ERROR,
                        "return" => crate::runtime::TCL_RETURN,
                        "break" => crate::runtime::TCL_BREAK,
                        "continue" => crate::runtime::TCL_CONTINUE,
                        n => match n.parse() {
                            Ok(n) => n,
                            Err(_) => {
                                return self.error(format!(
                                "bad completion code \"{n}\": must be ok, error, return, break, \
                                 continue, or an integer"
                            ))
                            }
                        },
                    };
                }
                Some("-level") => {
                    let text = self.literal_of(value, "return level")?.to_string();
                    level = match text.parse::<i32>() {
                        Ok(n) if n >= 0 => n,
                        _ => {
                            return self.error(format!(
                                "bad -level value: expected non-negative integer \
                                                but got \"{text}\""
                            ))
                        }
                    };
                }
                Some("-errorcode") => errorcode = Some(value),
                Some(other) if other.starts_with('-') => {
                    return self.error(format!("return option \"{other}\" is not supported"))
                }
                _ => break,
            }
            rest = tail;
        }
        let result = match rest {
            [] => None,
            [v] => Some(v),
            _ => {
                return self.error(
                    "wrong # args: should be \"return ?-code code? ?-level level? ?result?\"",
                )
            }
        };

        // The one case that is a plain frame return rather than a raised code:
        // an ordinary `return` from a procedure body, with no `catch` between
        // it and the frame it is returning from. Inside a `catch` even a bare
        // `return` is code 2 to that `catch`, which is what lets a script tell
        // "the body returned" from "the body finished".
        let plain = level == 1
            && code == crate::runtime::TCL_OK
            && self.catch_depth == 0
            && errorcode.is_none();
        if plain && self.scope.is_some() {
            match result {
                Some(w) => self.word(w)?,
                None => self.push_empty(),
            }
            self.emit(Op::ReturnValue, -1);
            self.push_empty();
            return Ok(());
        }
        // At the outermost level a plain `return` ends the script with its
        // result. Anything else is raised and spends its levels on the way out
        // — including against the outermost script itself, which is why
        // `return -code error zap` there is the error and `catch {return 7}`
        // there is still code 2.
        if plain && self.scope.is_none() {
            match result {
                Some(w) => self.word(w)?,
                None => self.push_empty(),
            }
            self.emit(Op::ReturnValue, -1);
            self.push_empty();
            return Ok(());
        }
        // Pushed under the message so the handler, which pops level/code/message
        // in that order, finds it last. The inline operand says it is there —
        // `RAISE` is emitted from two places and only this one can state a code.
        if let Some(w) = errorcode {
            self.word(w)?;
        }
        match result {
            Some(w) => self.word(w)?,
            None => self.push_empty(),
        }
        self.emit(Op::LoadInt(i64::from(code)), 1);
        self.emit(Op::LoadInt(i64::from(level)), 1);
        if errorcode.is_some() {
            self.emit(Op::Extended(ext::RAISE, 1), -4);
        } else {
            self.emit(Op::Extended(ext::RAISE, 0), -3);
        }
        // Control has left; the value keeps the depth arithmetic honest.
        self.push_empty();
        Ok(())
    }

    /// `global ?varname ...?` — no effect outside a procedure body.
    pub(crate) fn cmd_global(&mut self, args: &[Word]) -> Result<(), CompileError> {
        for w in args {
            let name = self.var_name_of(w)?;
            let Some(scope) = self.scope.as_mut() else {
                continue;
            };
            if scope.locals.contains_key(&name) {
                return self.error(format!("variable \"{name}\" already exists"));
            }
            scope.globals.insert(name);
        }
        self.push_empty();
        Ok(())
    }
}

/// The slot scope a procedure body starts with: one slot per formal parameter,
/// in declaration order, matching the prologue's `Op::SetSlot` sequence.
/// A procedure's slots, by name, indexed by slot number.
///
/// `Scope::locals` maps the other way and is sparse in neither direction, but a
/// frame answers by index, so this is the order fusevm wants. A slot with no
/// name cannot arise from `proc` — every slot is allocated for a mention — but
/// the vector is filled defensively so an index is never wrong by one.
fn slot_names_of(scope: &crate::compiler::Scope) -> Vec<String> {
    let mut names = vec![String::new(); scope.next_slot as usize];
    for (name, &slot) in &scope.locals {
        if let Some(at) = names.get_mut(slot as usize) {
            *at = name.clone();
        }
    }
    names
}

pub(crate) fn scope_for(sig: &Signature) -> Scope {
    let mut scope = Scope::default();
    for (i, p) in sig.params.iter().enumerate() {
        scope.locals.insert(p.name.clone(), i as u16);
    }
    scope.next_slot = sig.params.len() as u16;
    scope
}