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
// Regression tests for inline lambdas (Phase 1 + Phase 2).
//
// Inline lambda = `(params>return;body)` literal passed where a fn-ref is
// expected (HOF arg position). The parser lifts each lambda to a synthetic
// top-level `Decl::Function { name: "__lit_N", ... }` and replaces the call
// site with `Expr::Ref("__lit_N")`, so the rest of the toolchain (verifier,
// tree interpreter, fmt, python codegen, ...) treats it identically to a
// named helper.
//
// Phase 2 adds closure capture: free variables in the body get lifted as
// trailing params on the synthetic decl, and the call site emits
// `Expr::MakeClosure { fn_name, captures }` which evaluates to a
// `Value::Closure { fn_name, captures }` runtime value. Closure-aware HOFs
// append the captures after the per-item args at each call, matching the
// existing single-ctx form (#186) generalised to N captures.
//
// With #384 (VM closure support) + #385 (Cranelift closure parity) +
// #387 (HOF native closure dispatch) all merged, closure capture works
// across every engine. Phase 2 PR4 (this file) parameterises every test
// over tree, VM, and Cranelift via `run_all`.
//
// Tests that exercise HOFs which are still routed through the tree-bridge
// on VM / Cranelift (`srt`, `grp`, `uniqby`) stay tree-only with a TODO
// pointing at PR 3c (#391) — once that lands the helper switch is trivial.
use std::process::Command;
use std::sync::atomic::{AtomicU64, Ordering};
fn ilo() -> Command {
Command::new(env!("CARGO_BIN_EXE_ilo"))
}
fn write_src(name: &str, src: &str) -> std::path::PathBuf {
static COUNTER: AtomicU64 = AtomicU64::new(0);
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
let mut path = std::env::temp_dir();
path.push(format!("ilo_lam_{name}_{}_{n}.ilo", std::process::id()));
std::fs::write(&path, src).expect("write src");
path
}
fn run_engine(engine: &str, src: &str, entry: &str, args: &[&str]) -> String {
let path = write_src(entry, src);
let mut cmd = ilo();
cmd.arg(&path).arg(engine).arg(entry);
for a in args {
cmd.arg(a);
}
let out = cmd.output().expect("failed to run ilo");
let _ = std::fs::remove_file(&path);
assert!(
out.status.success(),
"ilo {engine} failed for `{src}`: stderr={}",
String::from_utf8_lossy(&out.stderr)
);
String::from_utf8_lossy(&out.stdout).trim().to_string()
}
/// Run `src` across every engine and assert each produces `expected`.
/// Use this by default — closure capture works natively on tree, VM,
/// and Cranelift after #384 + #385 + #387.
fn run_all(src: &str, entry: &str, args: &[&str], expected: &str) {
for engine in ["--vm", "--jit"] {
let actual = run_engine(engine, src, entry, args);
assert_eq!(
actual, expected,
"engine {engine} produced {actual:?}, expected {expected:?} for src `{src}`"
);
}
}
// ── srt: 1-arg key fn ──────────────────────────────────────────────────────
#[test]
fn srt_inline_key_by_length() {
let src = "f ws:L t>L t;srt (s:t>n;len s) ws";
run_all(
src,
"f",
&["[\"banana\",\"fig\",\"apple\"]"],
"[fig, apple, banana]",
);
}
#[test]
fn srt_inline_key_absolute_value() {
// Body uses `abs` builtin — no captures, no helper.
let src = "f xs:L n>L n;srt (x:n>n;abs x) xs";
run_all(src, "f", &["[-3,1,-5,2]"], "[1, 2, -3, -5]");
}
// ── flt: 1-arg predicate ───────────────────────────────────────────────────
#[test]
fn flt_inline_predicate() {
let src = "f xs:L n>L n;flt (x:n>b;>x 0) xs";
run_all(src, "f", &["[-2,3,-1,4,0]"], "[3, 4]");
}
// ── map: 1-arg transform ───────────────────────────────────────────────────
#[test]
fn map_inline_double() {
let src = "f xs:L n>L n;map (x:n>n;*x 2) xs";
run_all(src, "f", &["[1,2,3]"], "[2, 4, 6]");
}
// ── fld: 2-arg accumulator ─────────────────────────────────────────────────
#[test]
fn fld_inline_sum_of_squares() {
let src = "f xs:L n>n;fld (a:n x:n>n;+a *x x) xs 0";
run_all(src, "f", &["[1,2,3,4]"], "30");
}
// ── fld: multi-statement body (let + final expression) — ILO-50 regression ─
// Reported: `fld (m:_ a:n>_;b=*a 2;+m b) xs init` allegedly dropped the let
// statement from the lambda body. Verified cannot reproduce; this test locks
// the correct behaviour so any future parser regression is caught immediately.
#[test]
fn fld_multi_stmt_lambda_body_preserves_let() {
// Both `b=*a 2` (let) and `+m b` (expr) must survive in the lifted decl.
// If the let is dropped, doubling is skipped and the result is 6 (not 12).
let src = "f xs:L n>n;fld (m:_ a:n>_;b=*a 2;+m b) xs 0";
run_all(src, "f", &["[1,2,3]"], "12");
}
#[test]
fn fld_multi_stmt_lambda_three_stmts() {
// Three-statement body: two lets + final expr. Ensures the `;` loop in
// `parse_lambda_body` iterates past the first let without stopping early.
let src = "f xs:L n>n;fld (m:_ a:n>_;x=a;y=*x 2;+m y) xs 0";
run_all(src, "f", &["[1,2,3]"], "12");
}
// ── multi-statement body (let + final expression) ──────────────────────────
#[test]
fn lambda_multi_statement_body() {
let src = "f xs:L n>L n;srt (x:n>n;sq=*x x;sq) xs";
run_all(src, "f", &["[-3,1,-5,2]"], "[1, 2, -3, -5]");
}
// ── lambda calling a top-level helper (HOF inside HOF) ─────────────────────
#[test]
fn lambda_can_call_top_level_helper() {
let src = "dbl x:n>n;*x 2\nf xs:L n>L n;map (x:n>n;dbl x) xs";
run_all(src, "f", &["[1,2,3]"], "[2, 4, 6]");
}
// ── lambda calling a builtin ───────────────────────────────────────────────
#[test]
fn lambda_can_call_builtin() {
let src = "f xs:L n>L n;srt (x:n>n;abs x) xs";
run_all(src, "f", &["[-3,1,-5,2]"], "[1, 2, -3, -5]");
}
// ── Multiple lambdas in one function (counter increments) ──────────────────
#[test]
fn multiple_lambdas_in_one_function() {
let src = "f xs:L n>L n;ys=map (x:n>n;*x 2) xs;flt (x:n>b;>x 4) ys";
run_all(src, "f", &["[1,2,3,4]"], "[6, 8]");
}
// ── Lambda inside a top-level helper, used twice via different entries ─────
#[test]
fn lambda_inside_helper() {
let src = "
sorted xs:L n>L n;srt (x:n>n;abs x) xs
f xs:L n>L n;sorted xs
";
run_all(src, "f", &["[-3,1,-5,2]"], "[1, 2, -3, -5]");
}
// ── Phase 2: closure capture works ─────────────────────────────────────────
#[test]
fn closure_capture_single_var_filter() {
// Single capture: `thr` is in the enclosing fn's scope and the lambda
// references it. The parser lifts `__lit_0(x, thr)` and emits a
// MakeClosure at the call site; flt appends the capture to each call.
let src = "f xs:L n thr:n>L n;flt (x:n>b;>x thr) xs";
run_all(src, "f", &["[1,5,3,8,2]", "4"], "[5, 8]");
}
#[test]
fn closure_capture_in_sort_key() {
// `srt` with an inline key that closes over `target`.
let src = "f xs:L n target:n>L n;srt (x:n>n;abs -x target) xs";
run_all(src, "f", &["[1,5,10,20]", "8"], "[10, 5, 1, 20]");
}
#[test]
fn closure_capture_in_map() {
// `map` with an inline transform that closes over `bump`.
let src = "f xs:L n bump:n>L n;map (x:n>n;+x bump) xs";
run_all(src, "f", &["[1,2,3]", "10"], "[11, 12, 13]");
}
#[test]
fn closure_capture_in_fld() {
// `fld` with an inline reducer that closes over `weight`.
let src = "f xs:L n weight:n>n;fld (a:n x:n>n;+a *x weight) xs 0";
run_all(src, "f", &["[1,2,3,4]", "5"], "50");
}
#[test]
fn closure_capture_multiple_vars() {
// Two captures: `lo` and `hi` both appear in the body. Both lift as
// trailing params on the synthetic decl, and both flow as captures.
let src = "f xs:L n lo:n hi:n>L n;flt (x:n>b;&(>=x lo) <=x hi) xs";
run_all(src, "f", &["[1,3,5,7,9,11]", "3", "7"], "[3, 5, 7]");
}
#[test]
fn closure_capture_text_value() {
// Capture a Text value, not just numbers. By-value snapshot semantics.
let src = "f ws:L t prefix:t>L t;flt (w:t>b;has w prefix) ws";
run_all(
src,
"f",
&["[\"apple\",\"banana\",\"apricot\"]", "ap"],
"[apple, apricot]",
);
}
#[test]
fn closure_capture_by_value_snapshot() {
// The capture is snapshot when the closure is constructed, not read
// live at each call. We mutate the source local after the `srt` runs
// (well — srt has already completed by then). This just exercises that
// mutating the capture's source name post-construction is irrelevant
// because srt already consumed it. The real check is value-equality.
let src = "f xs:L n bias:n>L n;ys=srt (x:n>n;+x bias) xs;ys";
run_all(src, "f", &["[3,1,2]", "0"], "[1, 2, 3]");
}
// ── Phase 1 ctx-arg form is still supported alongside captures ─────────────
#[test]
fn ctx_arg_form_works_with_inline_lambda() {
// Phase 1 capture rejection nudges users to ctx-arg form, which already
// works for inline lambdas too — the lambda just takes an extra param.
let src = "f xs:L n thr:n>L n;flt (x:n c:n>b;>x c) thr xs";
run_all(src, "f", &["[1,5,3,8,2]", "4"], "[5, 8]");
}
// ── No regression: grouped parenthesised expressions still parse ───────────
#[test]
fn grouped_expression_still_parses() {
// `(+a b)` is a grouped expression, not a lambda — no `ident:` and no
// leading `>`. Must not trip the inline-lambda lookahead.
let src = "f a:n b:n>n;*(+a b) 2";
run_all(src, "f", &["3", "4"], "14");
}
// ── No regression: existing named-helper HOF call still works ──────────────
#[test]
fn named_helper_hof_unaffected() {
let src = "k x:n>n;abs x\nf xs:L n>L n;srt k xs";
run_all(src, "f", &["[-3,1,-5,2]"], "[1, 2, -3, -5]");
}
// ── Lambda inside foreach body shadowing is honored ────────────────────────
#[test]
fn lambda_local_binding_shadows_nothing_outside() {
// The `s` inside the lambda is a param, not a capture of any outer name.
// Even though there's no outer `s`, this exercises the param/local
// resolution path explicitly.
let src = "f ws:L t>L t;srt (s:t>n;n=len s;n) ws";
run_all(
src,
"f",
&["[\"banana\",\"fig\",\"apple\"]"],
"[fig, apple, banana]",
);
}