plum

#treesitter#compiler#wasm

git clone https://git.pyrossh.dev/plum

A statically typed, imperative programming language inspired by rust, python


861d398 — Peter John 2026-11-03T16:08:41+05:30
tail calls: plan; default FnCall.tail at construction sites; run markTailCalls in typedSource
docs/plans/2026-11-03-tail-calls.md ADDED
@@ -0,0 +1,126 @@
1
+ # Tail calls
2
+
3
+ **Review plan:** docs/plans/2026-11-03-tail-calls.review.md
4
+ **Goal:** calls in tail position use no stack (self calls become loops, others `return_call`), tail recursion modulo cons for constructor-wrapped recursive calls, larger host stack, depth tests at 1,000,000.
5
+ **Architecture:** plum-core `tail` pass marks tail calls on the AST → plum-core `trmc` pass rewrites `C(.., f: self(..))` tails into a hole-filling loop → codegen emits `loop`+`br` for self tail calls and `return_call`/`return_call_ref` for other tail calls → hosts enable wasm tail calls and an 8 MiB stack.
6
+ **Tech stack:** Rust (plum-core, plum-wasm-codegen with wasm-encoder 0.220, wasmtime 47 in plum-cli/plum-runtime/plum-game), Plum.
7
+ **Spec:** docs/plans/2026-11-03-tail-calls.review.md
8
+ **Detail:** contracts
9
+ **Target-min:** 30
10
+
11
+ ## Global constraints
12
+ - Work only in /Users/pyrossh/Code/plum-pure-scene-values (branch pure-scene-values). Never touch /Users/pyrossh/Code/plum.
13
+ - Plum file tests: `touch plum-cli/src/main.rs && cargo build --quiet -p plum-cli && ./target/debug/plum test <file>`. Rust: build `cargo build --tests -p <crate>` and run test binaries; `touch` edited .rs first. Whole Plum sweep: `./target/debug/plum test plum-std plum-examples` must stay green (594 tests).
14
+ - Tail position: the value of a fn body; the last expr of a block in tail position; each `match`/`if`/ternary arm in tail position; `return e`. NOT tail: inside `try`/`?`/`!`/`??`, inside a `test`, under a `for`, as an argument, before `defer`-like cleanup, or in an export fn's boundary wrapper.
15
+ - Defaults (review plan): automatic tail calls and TRMC, self calls as loops, wasm tail calls required, frames dropped from backtraces.
16
+ - Known bug R5: same local name with different types across match arms gives invalid wasm; loop-converted params keep one fixed type.
17
+ - Ownership (plum-checker/src/ownership.rs) must keep passing; do not edit it unless your task owns it.
18
+ - Inline variant syntax: `enum Link[T] = Nil | Cons(head: T, tail: Link[T])`, `fun f(x) -> R = match x` (see plum-examples/functional.plum).
19
+
20
+ ```mermaid
21
+ flowchart LR
22
+ AST["checked AST"] --> TP["tail pass (marks)"] --> TR["trmc rewrite"] --> CG["codegen: loop+br / return_call"] --> W[wasm]
23
+ H["hosts: wasm_tail_call + 8 MiB stack"] --> W
24
+ ```
25
+
26
+ File structure: each task's Owns. Spec coverage: What you will get (T1-T5), What will be built (T1-T6; benchmark by coordinator), How it fits together (diagram), Decisions you can change (Global constraints), Risks (R1 T2, R2 T2/T5, R3 T2, R4 T4), Out of scope (not built), How we will know it works (T5, T6), Rough time and cost (schedule).
27
+
28
+ ## Task graph
29
+ ```mermaid
30
+ flowchart LR
31
+ T1[tail pass]
32
+ T2[codegen tail calls]
33
+ T3[host config]
34
+ T4[trmc rewrite]
35
+ T5[depth tests]
36
+ T6[deep fixture]
37
+ %% taskgraph
38
+ T1 --> T2
39
+ T1 --> T4
40
+ T2 --> T5
41
+ T3 --> T5
42
+ T4 --> T5
43
+ ```
44
+
45
+ ## Analysis
46
+
47
+ ### T1 tail-position pass · Complexity 4 · Est. context 45k
48
+ **Owns:** plum-core/src/tail.rs, plum-core/src/lib.rs, plum-core/src/ast.rs, plum-core/tests/tail_test.rs
49
+ **Components:** 1
50
+ **LOC owned:** 160
51
+ **Tests:** fast
52
+ **Model:** sonnet
53
+ **Consumes:** none
54
+ **Produces:** tail::markTailCalls(src: &mut ast::Source) · tail::tailCalls(f: &ast::Fn) -> Vec<TailSite> · ast call nodes gain `tail: TailKind` (`NotTail | SelfCall | Other | Closure`)
55
+ e.g. `tailCalls(sumAcc)` -> `[TailSite(callee: "sumAcc", kind: SelfCall, line: 3)]`
56
+ e.g. `tailCalls(length)` (`1 + length(t)`) -> `[]`
57
+ **Review Focus:** deep: every rule in Global constraints' tail-position list, nested blocks, `= match` bodies, methods (`self.f(..)` on the same type is SelfCall), closures (a call inside a closure body is tail relative to the closure only).
58
+ **Test code:** tail_test.rs: `self_tail_in_match_arm`, `self_tail_in_if_else`, `not_tail_under_binary_op`, `not_tail_as_argument`, `not_tail_inside_try`, `other_fn_tail`, `mutual_recursion_both_tail`, `method_self_tail`, `closure_value_call_tail` (kind Closure), `return_expr_tail`, `block_last_expr_tail`. Adding the AST field: default `NotTail` at every construction site (any crate; list them).
59
+ **Steps:** pass runs after lowering and before codegen; wire the call in the shared pipeline entry that both `plum test` and `plum compile` use (find it; report where).
60
+
61
+ ### T4 tail recursion modulo cons · Complexity 6 · Est. context 55k
62
+ **Owns:** plum-core/src/trmc.rs, plum-core/tests/trmc_test.rs
63
+ **Components:** 1
64
+ **LOC owned:** 170
65
+ **Tests:** fast
66
+ **Model:** sonnet
67
+ **Consumes:** tail::tailCalls(f: &ast::Fn) -> Vec<TailSite>
68
+ **Produces:** trmc::rewrite(src: &mut ast::Source) -> Vec<String>
69
+ e.g. `rewrite` on functional.plum's `map` -> `["map"]` (names of rewritten fns)
70
+ **Review Focus:** deep/state R4: only a single recursive call as one field of a constructor (record or inline variant) in tail position, other fields evaluated first in source order; results equal the original for every input; fields must be writable (if inline-variant hidden records are immutable, restrict to what is writable and report).
71
+ **Test code:** trmc_test.rs (compile and run via plum-wasm-codegen's test helper or the CLI): `map_rewritten_same_result`, `filter_rewritten_same_result` (the `rest := filter(..)` then `if` form is NOT TRMC: assert not rewritten), `fromTo_rewritten`, `append_rewritten`, `two_recursive_fields_not_rewritten` (tree map), `side_effect_order_kept` (a field before the recursive one calls a counter closure: same order).
72
+ **Steps:** rewrite into a loop that allocates each cell with a placeholder for the recursive field and writes it on the next iteration; run before T1's marking so the loop has no self call. Register the pass after `tail::markTailCalls`'s caller (same pipeline entry).
73
+
74
+ ## Codegen and hosts
75
+
76
+ ### T2 codegen tail calls · Complexity 6 · Est. context 70k
77
+ **Owns:** plum-wasm-codegen/src/lib.rs, plum-wasm-codegen/tests/tail_calls_e2e_test.rs
78
+ **Components:** 1
79
+ **LOC owned:** 200
80
+ **Tests:** fast
81
+ **Model:** sonnet
82
+ **Consumes:** tail::markTailCalls(src: &mut ast::Source)
83
+ **Produces:** none
84
+ **Review Focus:** state R1/R3: a fn with a SelfCall tail gets its body wrapped in `loop`; the tail call evaluates all args into temps, then `local.set`s params, then `br` to the loop (no param read after its set); a closure created in the body that captures a param copies it into a fresh local per iteration. Other/Closure tails emit `return_call` / `return_call_ref`. Error-union results and export wrappers never tail-call. R2: own params reassigned by the loop.
85
+ **Test code:** tail_calls_e2e_test.rs (run wasm with tail calls enabled in the test engine): `self_tail_million` (sumAcc over build(1_000_000) = 500000500000), `self_tail_args_swap` (`gcd(b, a % b)`), `mutual_tail_million` (isEven/isOdd 1_000_001), `closure_tail_call_runs` (deep, through `fn` value), `method_self_tail` , `own_param_self_tail` (own record threaded through 100_000 iterations), `closure_captures_iteration_value`, `wasm_has_no_call_in_loop` (wasmparser: sumAcc contains `br`, no `call` to itself), `return_call_emitted` (isEven contains return_call). Existing codegen suite green.
86
+
87
+ ### T3 host config · Complexity 1 · Est. context 22k
88
+ **Owns:** plum-cli/src/main.rs, plum-runtime/src/main.rs, plum-game/src/host.rs
89
+ **Components:** 1
90
+ **LOC owned:** 20
91
+ **Tests:** fast
92
+ **Kind:** mechanical
93
+ **Model:** haiku
94
+ **Consumes:** none
95
+ **Produces:** none
96
+ **Review Focus:** state: every `wasmtime::Config::new()` (plum-cli/src/main.rs:796 and :946, plum-runtime/src/main.rs:361, plum-game/src/host.rs:162) gets `.wasm_tail_call(true)` and `.max_wasm_stack(8 << 20)`.
97
+ **Test code:** `cargo build --workspace` clean; `./target/debug/plum test plum-examples/functional.plum` passes; the probe `length(fromTo(0, 50000)) == 50000` in a scratch file under /private/tmp/claude-501/ passes (was "call stack exhausted" at 10,000).
98
+
99
+ ## Verification
100
+
101
+ ### T5 depth tests · Complexity 3 · Est. context 35k
102
+ **Owns:** plum-examples/tail_calls.plum
103
+ **Components:** 1
104
+ **LOC owned:** 120
105
+ **Tests:** fast
106
+ **Model:** sonnet
107
+ **Consumes:** none
108
+ **Produces:** none
109
+ **Review Focus:** deep: each test at 1,000,000 unless stated.
110
+ **Test code:** module tail_calls with `enum Link[T] = Nil | Cons(head: T, tail: Link[T])`: `tail_sum_million`, `tail_build_million`, `fold_million`, `mutual_even_odd_million`, `trmc_map_million`, `trmc_fromTo_million`, `closure_tail_million`, `own_record_loop_100k`, `tree_recursion_depth_recorded` (a non-tail tree depth that passes at 8 MiB, documented in a comment with the measured limit). Update the "not tail call optimised" comment at the top of plum-examples/functional.plum? NO (not owned): report it.
111
+
112
+ ### T6 deep state/view fixture · Complexity 2 · Est. context 30k
113
+ **Owns:** plum-game/web-test/fixtures/stress_state_view_deep.plum
114
+ **Components:** 1
115
+ **LOC owned:** 90
116
+ **Tests:** fast
117
+ **Model:** sonnet
118
+ **Consumes:** none
119
+ **Produces:** none
120
+ **Review Focus:** numeric: same 5,750 actors and formulas as stress_state_view.plum, but the 5,000 dancers in ONE `Link` (5,000-deep recursion in step/view/draw).
121
+ **Test code:** `plum test` on the fixture (2 tests mirroring stress_state_view.plum); `plum compile` succeeds. Expected to overflow before T2/T3/T4 and run after: report both.
122
+
123
+ ## Review Focus
124
+ - compiler (library): deep, state. Risks: wrong tail-position classification; param clobbering in loops; TRMC changing evaluation order; ownership interplay.
125
+ - hosts: state. Risk: a missed Config.
126
+ **Probe entry:** run=./target/debug/plum test
docs/plans/2026-11-03-tail-calls.review.md ADDED
@@ -0,0 +1,72 @@
1
+ # Tail calls: review plan
2
+
3
+ **Builds on:** branch `pure-scene-values` (phase B, commit 64aab59). The recursive state/view and `std/game/Picture` are the motivating code. The supercompiler is a later, separate plan.
4
+
5
+ ## Summary
6
+ Plum recursion currently runs out of stack between 5,000 and 10,000 frames, measured on 2026-11-03 under `plum test` (wasmtime 47). Tail-shaped functions overflow just the same:
7
+
8
+ | probe | depth | result |
9
+ |---|---|---|
10
+ | `length(fromTo(0, n))` (not tail) | 5,000 | ok |
11
+ | same | 10,000 | `wasm trap: call stack exhausted` |
12
+ | `sumAcc(build(n, Nil), 0)` (tail) | 5,000 | ok |
13
+ | same | 20,000 / 1,000,000 | stack exhausted |
14
+
15
+ This plan makes calls in tail position free, so tail-recursive functions run at any depth. It also turns the most common non-tail shape, building a list as you go (`Cons(head: f(h), tail: map(t, f))`), into a tail call. After that, `map`, `filter`, `fromTo`, `view` and `step` over long `Link`s no longer overflow either.
16
+
17
+ ## What you will get
18
+ - **Self tail calls become loops.** A function that calls itself in tail position (the last thing a `match` arm, `if` branch or block does) compiles to a jump back to its start with the parameters reassigned. No new stack frame is used, and it runs at loop speed. Examples: `sumAcc`, `build`, `fold`, `drawNodes(tail)`.
19
+ - **Other tail calls use wasm `return_call`.** A call to a different function in tail position, mutual recursion (`isEven` / `isOdd`), and calls through a closure value use `return_call` / `return_call_ref`. They also stop growing the stack.
20
+ - **Tail recursion modulo cons.** A function whose tail is a constructor around one recursive call, such as `Cons(head: x, tail: f(rest))`, is rewritten to fill a hole in the result as it goes. That makes it a loop. This covers `map`, `filter`, `fromTo`, `append` and Picture/state-view `view` builders. Opt-in rules are listed under Decisions.
21
+ - **`plum test` and the host stack.** Raise wasmtime's stack limit to a stated size for deep non-tail recursion that remains (tree recursion such as `bounds` over a deep Group). Report the new limit.
22
+ - **Diagnostics.** `plum build --explain-tail fn` (or a test helper) lists which calls were made tail calls. Tests can then assert on a function's tail calls without inspecting the wasm.
23
+
24
+ ## What will be built
25
+ | Part | What it does | Size |
26
+ |---|---|---|
27
+ | Tail-position analysis | Marks calls in tail position through `match`/`if`/blocks/`= match` bodies, early `return`, and error-union `try` (not tail) | S |
28
+ | Self-tail loop | Codegen wraps the body in a `loop`, and a self tail call becomes param `local.set` + `br` | M |
29
+ | `return_call` | Codegen emits `return_call` / `return_call_ref` for other tail calls; wasmtime `Config::wasm_tail_call(true)` in plum-cli, plum-runtime and plum-game | S |
30
+ | Tail modulo cons | Pass: `C(.., field: f(..), ..)` in tail position, with `f` the enclosing fn and one recursive field → destination-passing loop (allocate the node with a hole, write the hole next iteration) | M |
31
+ | Stack limit | `max_wasm_stack` set in all three hosts; depth probe recorded | S |
32
+ | Tests and probes | Depth tests at 1,000,000 for tail and TRMC functions, mutual recursion, closures in tail position, and every existing suite green | M |
33
+ | Benchmark | Recursive state/view stress fixture re-measured; a 5,000-deep single-list variant added to show the recursion depth limit is gone | S |
34
+
35
+ ## How it fits together
36
+ ```mermaid
37
+ flowchart LR
38
+ AST["checked AST"] --> TP["tail-position analysis"]
39
+ TP --> TRMC["tail modulo cons rewrite"]
40
+ TRMC --> CG["codegen"]
41
+ CG --> L["self tail call: loop + br"]
42
+ CG --> RC["other tail call: return_call / return_call_ref"]
43
+ L --> W[wasm-gc]
44
+ RC --> W
45
+ ```
46
+
47
+ ## Decisions you can change
48
+ - **Which calls.** Default: every call in tail position is a tail call, automatically. Alternative: an explicit marker (for example `return tail f(x)`, or an `@tailrec` annotation that errors when the call isn't in tail position).
49
+ - **Tail modulo cons.** Default: automatic for a single recursive field in a constructor at tail position. Alternative: opt-in per function (OCaml's `[@tail_mod_cons]`).
50
+ - **Self calls.** Default: a loop, not `return_call`, because it's faster and needs no engine support. Alternative: `return_call` everywhere (simpler, slower).
51
+ - **Browser support.** Default: require wasm tail calls (Chrome 112+, Firefox 121+, Safari 18.2+). Alternative: loops only, no `return_call`, for older engines; mutual recursion would then still grow the stack.
52
+ - **Stack traces.** Tail calls drop frames from wasm backtraces. Default: accept that. Alternative: a debug flag that turns tail calls off.
53
+
54
+ ## Risks
55
+ - **R1: R5 match-arm local bug.** Reassigning params in a loop meets the known invalid-wasm bug where the same name gets different types. The analysis gives every loop param one fixed type, and a regression test covers it.
56
+ - **R2: owned values.** Tail-call reassignment counts as a move under the ownership checker. Self tail calls with `own` params must not trip "used after it was moved". Tests cover it.
57
+ - **R3: closures capturing params.** A closure created in an iteration that captures a param must see that iteration's value. Loop-converted params are reassigned, so captured params are copied into a fresh local per iteration.
58
+ - **R4: TRMC and wasm-gc.** Filling a hole needs a mutable field. Plum records are mutable today, so this holds; immutable struct/inline-variant layouts would need a builder type. If hidden inline-variant records turn out to be immutable, TRMC covers records only, or is deferred.
59
+
60
+ ## Out of scope
61
+ - The supercompiler (driving/folding, deforestation, fusing `view` into `draw`): the next plan, once this lands.
62
+ - General CPS, or any non-tail recursion beyond tail modulo cons (tree recursion keeps using the stack).
63
+ - Changing the existing for-loop forms.
64
+
65
+ ## How we will know it works
66
+ - **Depth tests:** the probes above all pass at 1,000,000 for tail-recursive functions (`sumAcc`, `build`, `fold`), for mutual recursion, for TRMC functions (`map`, `filter`, `fromTo`), and for a closure called in tail position. Non-tail tree recursion still fails at a stated depth, which is recorded.
67
+ - **Explain output:** a test asserts which calls are tail calls for a small set of functions (tail, not tail, inside `try`, inside a closure).
68
+ - **Existing suites:** the whole suite stays green (1,765 tests today), and so do the host parity test and both browser checks.
69
+ - **Benchmark:** the recursive state/view row is re-measured, plus a 5,000-deep single-list fixture that overflows before this change and runs after it.
70
+
71
+ ## Rough time and cost
72
+ About 7 parts. The analysis comes first, then the self-loop and `return_call` in parallel, then TRMC, then tests and the benchmark. Rough agent time is 45–70 minutes.
plum-checker/src/lib.rs CHANGED
@@ -1179,7 +1179,14 @@ pub fn checkSource(source: &ast::Source) -> CheckResult<()> {
1179
1179
  /// instantiated type of every expression (keyed by expression address) and the
1180
1180
  /// set of generic instantiations (see `TypedInfo`) for codegen.
1181
1181
  pub fn typedSource(source: &ast::Source) -> CheckResult<TypedInfo> {
1182
- let (result, info) = checkSourceFull(source);
1182
+ let (result, mut info) = checkSourceFull(source);
1183
+ // TODO(tail-calls): the tail-recursion-modulo-cons AST rewrite pass goes HERE,
1184
+ // on the fully lowered program, immediately before `markTailCalls`.
1185
+ // Mark tail calls on the final lowered program codegen compiles. Flags are set
1186
+ // in place, so the address-keyed type info stays valid.
1187
+ if let Some(lowered) = info.lowered.as_mut().and_then(std::rc::Rc::get_mut) {
1188
+ plum_core::tail::markTailCalls(lowered);
1189
+ }
1183
1190
  result.map(|()| info)
1184
1191
  }
1185
1192
 
@@ -4428,6 +4435,7 @@ fn inferOperatorCall(
4428
4435
  .collect(),
4429
4436
  line: 0,
4430
4437
  column: 0,
4438
+ tail: ast::TailKind::NotTail,
4431
4439
  })
4432
4440
  })
4433
4441
  .clone()
plum-core/src/ctors.rs CHANGED
@@ -374,6 +374,7 @@ pub fn lowerCtors(source: &mut Source) -> Vec<String> {
374
374
  args: vec![],
375
375
  line: line,
376
376
  column: column,
377
+ tail: TailKind::NotTail,
377
378
  }),
378
379
  })),
379
380
  0,
@@ -502,5 +503,6 @@ fn rewriteCall(e: &mut Expr, plans: &HashMap<String, Plan>, errors: &mut Vec<Str
502
503
  args,
503
504
  line: 0,
504
505
  column: 0,
506
+ tail: TailKind::NotTail,
505
507
  });
506
508
  }
plum-core/src/forms.rs CHANGED
@@ -301,6 +301,7 @@ fn method(obj: &str, name: &str, args: Vec<Expr>) -> Expr {
301
301
  args: args.into_iter().map(Arg::Positional).collect(),
302
302
  line: 0,
303
303
  column: 0,
304
+ tail: TailKind::NotTail,
304
305
  }),
305
306
  }))
306
307
  }
@@ -1867,6 +1868,7 @@ fn yieldCall(args: Vec<Expr>) -> Expr {
1867
1868
  args: args.into_iter().map(Arg::Positional).collect(),
1868
1869
  line: 0,
1869
1870
  column: 0,
1871
+ tail: TailKind::NotTail,
1870
1872
  })
1871
1873
  }
1872
1874
 
plum-core/src/parser.rs CHANGED
@@ -1607,6 +1607,7 @@ impl<'a> AstParser<'a> {
1607
1607
  args,
1608
1608
  line: pos.row + 1,
1609
1609
  column: pos.column + 1,
1610
+ tail: TailKind::NotTail,
1610
1611
  })
1611
1612
  }
1612
1613
  None => AttrKind::Field(member),
@@ -1666,6 +1667,7 @@ impl<'a> AstParser<'a> {
1666
1667
  args,
1667
1668
  line: pos.row + 1,
1668
1669
  column: pos.column + 1,
1670
+ tail: TailKind::NotTail,
1669
1671
  })
1670
1672
  }
1671
1673
  None => AttrKind::Field(member),
@@ -1689,6 +1691,7 @@ impl<'a> AstParser<'a> {
1689
1691
  args: vec![Arg::Positional(Expr::Closure(Box::new(closure)))],
1690
1692
  line: pos.row + 1,
1691
1693
  column: pos.column + 1,
1694
+ tail: TailKind::NotTail,
1692
1695
  }),
1693
1696
  }))
1694
1697
  }
@@ -1716,6 +1719,7 @@ impl<'a> AstParser<'a> {
1716
1719
  args,
1717
1720
  line: pos.row + 1,
1718
1721
  column: pos.column + 1,
1722
+ tail: TailKind::NotTail,
1719
1723
  }
1720
1724
  }
1721
1725
 
@@ -1752,6 +1756,7 @@ impl<'a> AstParser<'a> {
1752
1756
  args: vec![part(0), part(1)],
1753
1757
  line: pos.row + 1,
1754
1758
  column: pos.column + 1,
1759
+ tail: TailKind::NotTail,
1755
1760
  }))
1756
1761
  }
1757
1762
  _ => {
plum-core/src/union_lower.rs CHANGED
@@ -168,6 +168,7 @@ impl<'a> Lowerer<'a> {
168
168
  args: vec![Arg::Positional(value)],
169
169
  line: 0,
170
170
  column: 0,
171
+ tail: TailKind::NotTail,
171
172
  }),
172
173
  })),
173
174
  0,
@@ -345,6 +346,7 @@ impl<'a> Lowerer<'a> {
345
346
  args: vec![],
346
347
  line: 0,
347
348
  column: 0,
349
+ tail: TailKind::NotTail,
348
350
  });
349
351
  }
350
352
  }
@@ -371,6 +373,7 @@ impl<'a> Lowerer<'a> {
371
373
  args: vec![Arg::Positional(inner)],
372
374
  line: 0,
373
375
  column: 0,
376
+ tail: TailKind::NotTail,
374
377
  });
375
378
  }
376
379
  }
@@ -696,6 +699,7 @@ isOwn: false,
696
699
  args,
697
700
  line: 0,
698
701
  column: 0,
702
+ tail: TailKind::NotTail,
699
703
  }),
700
704
  }));
701
705
  Case {
plum-wasm-codegen/src/lib.rs CHANGED
@@ -7586,6 +7586,7 @@ fn compileExprCore(
7586
7586
  .collect(),
7587
7587
  line: 0,
7588
7588
  column: 0,
7589
+ tail: ast::TailKind::NotTail,
7589
7590
  };
7590
7591
  compileVariantConstruction(info, &synthetic_call, expr, body, ctx, state)?;
7591
7592
  }
@@ -7645,6 +7646,7 @@ fn compileExprCore(
7645
7646
  args: vec![],
7646
7647
  line: 0,
7647
7648
  column: 0,
7649
+ tail: ast::TailKind::NotTail,
7648
7650
  };
7649
7651
  compileMethodCall(
7650
7652
  &object,
@@ -8414,6 +8416,7 @@ fn compileInterpolatedString(
8414
8416
  args: vec![],
8415
8417
  line: 0,
8416
8418
  column: 0,
8419
+ tail: ast::TailKind::NotTail,
8417
8420
  };
8418
8421
  if plum_checker::nominalName(&other)
8419
8422
  .is_some_and(|n| ctx.func_ids.contains_key(&format!("{}::toStr", n)))