Iter 7: first-class function references (no capture)
Top-level fn names are now usable as values, fn-typed parameters can be called as `f(args)`. KISS slice of "closures + HOFs + TIR": no TIR, no heap, no ABI shift — that bundle stays in Iter 8 where closure-with-capture and lambdas land together. Codegen: - Type::Fn lowers to LLVM `ptr`; sig travels via a per-fn-body `ssa_fn_sigs` sidetable on `Emitter`. - Term::Var falls through to `resolve_top_level_fn` when the name is not a local; emits `@ail_<m>_<def>` and registers the sig. - Term::App splits: static callee (builtin / current-module / qualified) keeps the existing direct path; otherwise lower the callee, look up the sig, emit indirect `call <ret> (<param-tys>) %fn(args...)`. - emit_fn registers fn-typed params in the sidetable on entry. - If branches propagate a fn-pointer sig to their phi when both arms match. Typechecker: unchanged. It already accepted `synth(callee)` against Type::Fn; the only blocker was codegen rejecting non-Var callees. Tests: 48 green (was 47). New `examples/hof.ail.json` and e2e `higher_order_apply_inc` exercise `apply(inc, 41) == 42` end-to-end. DESIGN.md: "What is not (yet) supported" rewritten — closures-with- capture and lambdas remain pending, first-class fn-refs added as positive bullet. Smoke-test list extended with `hof.ail.json`. JOURNAL.md: Iter 7 entry with rationale, scope choice, architecture self-check, Iter 8 plan. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -64,6 +64,14 @@ fn list_sum_via_match() {
|
||||
assert_eq!(stdout.trim(), "42");
|
||||
}
|
||||
|
||||
/// Iter 7: first-class function references — `apply(inc, 41)` must
|
||||
/// produce 42, exercising fn-typed parameters and indirect call.
|
||||
#[test]
|
||||
fn higher_order_apply_inc() {
|
||||
let stdout = build_and_run("hof.ail.json");
|
||||
assert_eq!(stdout.trim(), "42");
|
||||
}
|
||||
|
||||
/// Guards `ail diff`: a modified body changes the hash of `sum`, while
|
||||
/// `main` stays unchanged. Expects exit code 1, `changed` contains exactly
|
||||
/// `sum`, `unchanged` contains `main`, `added`/`removed` empty.
|
||||
|
||||
@@ -222,6 +222,12 @@ struct Emitter<'a> {
|
||||
/// Current basic block label. Set by `start_block` and is
|
||||
/// the single source of truth for `phi` operands.
|
||||
current_block: String,
|
||||
/// Iter 7: SSA value (or `@global`) -> its FnSig, for first-class
|
||||
/// function values. Populated whenever we lower a `Term::Var` to a
|
||||
/// top-level fn pointer or when a fn-typed parameter is bound at
|
||||
/// function entry. Used by `Term::App` when the callee is not a
|
||||
/// statically-known top-level name.
|
||||
ssa_fn_sigs: BTreeMap<String, FnSig>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -293,6 +299,7 @@ impl<'a> Emitter<'a> {
|
||||
types,
|
||||
ctor_index,
|
||||
current_block: String::new(),
|
||||
ssa_fn_sigs: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -378,6 +385,9 @@ impl<'a> Emitter<'a> {
|
||||
|
||||
self.locals.clear();
|
||||
self.counter = 0;
|
||||
// Per-fn body: the sidetable starts empty. Top-level fn references
|
||||
// get registered on demand by `lower_term(Term::Var)`.
|
||||
self.ssa_fn_sigs.clear();
|
||||
|
||||
let mut sig = format!(
|
||||
"define {ret} @ail_{module}_{name}(",
|
||||
@@ -385,17 +395,25 @@ impl<'a> Emitter<'a> {
|
||||
module = self.module_name,
|
||||
name = f.name
|
||||
);
|
||||
for (i, (pname, pty)) in f.params.iter().zip(llvm_param_tys.iter()).enumerate() {
|
||||
for (i, ((pname, pty), pty_ail)) in f
|
||||
.params
|
||||
.iter()
|
||||
.zip(llvm_param_tys.iter())
|
||||
.zip(param_tys.iter())
|
||||
.enumerate()
|
||||
{
|
||||
if i > 0 {
|
||||
sig.push_str(", ");
|
||||
}
|
||||
// SSA argument name: %arg_<name>
|
||||
sig.push_str(&format!("{} %arg_{}", pty, pname));
|
||||
self.locals.push((
|
||||
pname.clone(),
|
||||
format!("%arg_{}", pname),
|
||||
pty.clone(),
|
||||
));
|
||||
let pssa = format!("%arg_{}", pname);
|
||||
sig.push_str(&format!("{pty} {pssa}"));
|
||||
self.locals.push((pname.clone(), pssa.clone(), pty.clone()));
|
||||
// Iter 7: if this param is a function value, record its sig
|
||||
// so that `f(args)` inside the body can emit an indirect call.
|
||||
if let Some(fs) = fn_sig_from_type(pty_ail) {
|
||||
self.ssa_fn_sigs.insert(pssa, fs);
|
||||
}
|
||||
}
|
||||
sig.push_str(") {\n");
|
||||
self.body.push_str(&sig);
|
||||
@@ -432,10 +450,16 @@ impl<'a> Emitter<'a> {
|
||||
}),
|
||||
Term::Var { name } => {
|
||||
if let Some((_, ssa, ty)) = self.locals.iter().rev().find(|(n, _, _)| n == name) {
|
||||
Ok((ssa.clone(), ty.clone()))
|
||||
} else {
|
||||
Err(CodegenError::UnknownVar(name.clone()))
|
||||
return Ok((ssa.clone(), ty.clone()));
|
||||
}
|
||||
// Iter 7: bare reference to a top-level fn yields a fn-pointer
|
||||
// value of type `ptr`. Cross-module via `prefix.def`, current
|
||||
// module via plain `def`. Sidetable carries the sig.
|
||||
if let Some((global, sig)) = self.resolve_top_level_fn(name) {
|
||||
self.ssa_fn_sigs.entry(global.clone()).or_insert(sig);
|
||||
return Ok((global, "ptr".into()));
|
||||
}
|
||||
Err(CodegenError::UnknownVar(name.clone()))
|
||||
}
|
||||
Term::Let { name, value, body } => {
|
||||
let (val_ssa, val_ty) = self.lower_term(value)?;
|
||||
@@ -487,18 +511,50 @@ impl<'a> Emitter<'a> {
|
||||
ev = else_v,
|
||||
elbl = else_block_end,
|
||||
));
|
||||
// Iter 7: if both branches yield the same fn-pointer sig,
|
||||
// forward it to the phi SSA so subsequent indirect calls
|
||||
// can resolve.
|
||||
if then_ty == "ptr" {
|
||||
if let (Some(ts), Some(es)) =
|
||||
(self.ssa_fn_sigs.get(&then_v), self.ssa_fn_sigs.get(&else_v))
|
||||
{
|
||||
if ts.params == es.params && ts.ret == es.ret {
|
||||
let merged = ts.clone();
|
||||
self.ssa_fn_sigs.insert(phi.clone(), merged);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok((phi, then_ty))
|
||||
}
|
||||
Term::App { callee, args } => {
|
||||
let name = match callee.as_ref() {
|
||||
Term::Var { name } => name.clone(),
|
||||
_ => {
|
||||
return Err(CodegenError::Internal(
|
||||
"MVP: callee must be a variable".into(),
|
||||
));
|
||||
// Direct call when the callee is a `Var` referring to a
|
||||
// statically-known target (builtin, current-module fn,
|
||||
// qualified cross-module fn) AND not shadowed by a local.
|
||||
// Otherwise we fall through to the indirect-call path,
|
||||
// which lowers the callee to a fn-pointer and looks up
|
||||
// its sig in the sidetable.
|
||||
if let Term::Var { name } = callee.as_ref() {
|
||||
let shadowed = self.locals.iter().any(|(n, _, _)| n == name);
|
||||
if !shadowed && self.is_static_callee(name) {
|
||||
return self.lower_app(name, args);
|
||||
}
|
||||
};
|
||||
self.lower_app(&name, args)
|
||||
}
|
||||
let (callee_ssa, callee_ty) = self.lower_term(callee)?;
|
||||
if callee_ty != "ptr" {
|
||||
return Err(CodegenError::Internal(format!(
|
||||
"indirect call: callee type must be ptr, got {callee_ty}"
|
||||
)));
|
||||
}
|
||||
let sig = self
|
||||
.ssa_fn_sigs
|
||||
.get(&callee_ssa)
|
||||
.cloned()
|
||||
.ok_or_else(|| {
|
||||
CodegenError::Internal(format!(
|
||||
"indirect call: no FnSig recorded for `{callee_ssa}`"
|
||||
))
|
||||
})?;
|
||||
self.emit_indirect_call(&callee_ssa, &sig, args)
|
||||
}
|
||||
Term::Do { op, args } => self.lower_effect_op(op, args),
|
||||
Term::Ctor { type_name, ctor, args } => self.lower_ctor(type_name, ctor, args),
|
||||
@@ -838,6 +894,82 @@ impl<'a> Emitter<'a> {
|
||||
Ok((dst, sig.ret.clone()))
|
||||
}
|
||||
|
||||
/// Iter 7: indirect call through a fn-pointer SSA value. Sig must
|
||||
/// already be known (sidetable lookup happened in the caller).
|
||||
fn emit_indirect_call(
|
||||
&mut self,
|
||||
callee_ssa: &str,
|
||||
sig: &FnSig,
|
||||
args: &[Term],
|
||||
) -> Result<(String, String)> {
|
||||
if args.len() != sig.params.len() {
|
||||
return Err(CodegenError::Internal(format!(
|
||||
"indirect call arity mismatch: sig expects {}, got {}",
|
||||
sig.params.len(),
|
||||
args.len()
|
||||
)));
|
||||
}
|
||||
let mut compiled = Vec::new();
|
||||
for (a, exp_ty) in args.iter().zip(sig.params.iter()) {
|
||||
let (v, vty) = self.lower_term(a)?;
|
||||
if &vty != exp_ty {
|
||||
return Err(CodegenError::Internal(format!(
|
||||
"indirect call arg type mismatch: expected {exp_ty}, got {vty}"
|
||||
)));
|
||||
}
|
||||
compiled.push((v, vty));
|
||||
}
|
||||
let arglist = compiled
|
||||
.iter()
|
||||
.map(|(v, t)| format!("{t} {v}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
let dst = self.fresh_ssa();
|
||||
// LLVM indirect-call form: `call <ret>(<param-tys>) <ptr>(<args>)`.
|
||||
let param_tys = sig.params.join(", ");
|
||||
self.body.push_str(&format!(
|
||||
" {dst} = call {ret} ({ptys}) {callee_ssa}({arglist})\n",
|
||||
ret = sig.ret,
|
||||
ptys = param_tys,
|
||||
));
|
||||
Ok((dst, sig.ret.clone()))
|
||||
}
|
||||
|
||||
/// Iter 7: is `name` a callee that can be resolved at compile time
|
||||
/// (no fn-pointer needed)? True for builtin operators, the
|
||||
/// current-module top-level fns, and qualified `prefix.def`.
|
||||
/// Locals shadow this — the caller checks for that first.
|
||||
fn is_static_callee(&self, name: &str) -> bool {
|
||||
if builtin_binop(name).is_some() || name == "not" {
|
||||
return true;
|
||||
}
|
||||
if name.matches('.').count() == 1 {
|
||||
return true;
|
||||
}
|
||||
self.module_user_fns
|
||||
.get(self.module_name)
|
||||
.is_some_and(|m| m.contains_key(name))
|
||||
}
|
||||
|
||||
/// Iter 7: resolve `name` to a top-level fn-pointer (`@ail_<m>_<def>`)
|
||||
/// + its sig, or return None. Mirrors the dispatch in `lower_app` for
|
||||
/// fn refs only — operators / `not` are not first-class values, so
|
||||
/// `is_static_callee` excludes them here.
|
||||
fn resolve_top_level_fn(&self, name: &str) -> Option<(String, FnSig)> {
|
||||
if name.matches('.').count() == 1 {
|
||||
let (prefix, suffix) = name.split_once('.')?;
|
||||
let target = self.import_map.get(prefix)?;
|
||||
let sig = self.module_user_fns.get(target)?.get(suffix)?.clone();
|
||||
return Some((format!("@ail_{target}_{suffix}"), sig));
|
||||
}
|
||||
let sig = self
|
||||
.module_user_fns
|
||||
.get(self.module_name)?
|
||||
.get(name)?
|
||||
.clone();
|
||||
Some((format!("@ail_{module}_{name}", module = self.module_name), sig))
|
||||
}
|
||||
|
||||
fn lower_effect_op(&mut self, op: &str, args: &[Term]) -> Result<(String, String)> {
|
||||
match op {
|
||||
"io/print_int" => {
|
||||
@@ -950,12 +1082,31 @@ fn llvm_type(t: &Type) -> Result<String> {
|
||||
// intentional — otherwise `ptr` would mask a wrong value.
|
||||
_ => Ok("ptr".into()),
|
||||
},
|
||||
// Function values (Iter 7): all fn-pointers are opaque `ptr`
|
||||
// at the LLVM level. The actual signature travels via the
|
||||
// emitter's `ssa_fn_sigs` sidetable.
|
||||
Type::Fn { .. } => Ok("ptr".into()),
|
||||
other => Err(CodegenError::UnsupportedType(
|
||||
ailang_core::pretty::type_to_string(other),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds an `FnSig` (LLVM types only) from an AILang `Type::Fn`.
|
||||
/// Returns `None` for non-function types or if any param/ret type fails
|
||||
/// to lower (e.g. a residual `Type::Var` or `Forall` that the typechecker
|
||||
/// would reject before us).
|
||||
fn fn_sig_from_type(t: &Type) -> Option<FnSig> {
|
||||
if let Type::Fn { params, ret, .. } = t {
|
||||
let p: Result<Vec<String>> = params.iter().map(llvm_type).collect();
|
||||
let r = llvm_type(ret);
|
||||
if let (Ok(p), Ok(r)) = (p, r) {
|
||||
return Some(FnSig { params: p, ret: r });
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn builtin_binop(name: &str) -> Option<(&'static str, &'static str)> {
|
||||
Some(match name {
|
||||
"+" => ("add", "i64"),
|
||||
|
||||
+15
-5
@@ -229,11 +229,13 @@ ail build <module> — full pipeline → binary
|
||||
|
||||
## What is not (yet) supported
|
||||
|
||||
Snapshot of the boundary at the end of Iter 6. Items move out of this list
|
||||
Snapshot of the boundary at the end of Iter 7. Items move out of this list
|
||||
as iterations land; the JOURNAL records the exact iteration.
|
||||
|
||||
- No closures / higher-order functions. Will require a typed IR stage (TIR)
|
||||
before lowering.
|
||||
- No closures with capture / no anonymous lambdas. Both will require a
|
||||
typed IR stage (TIR) and closure conversion in lowering. First-class
|
||||
function references (top-level fns as values, fn-typed parameters,
|
||||
indirect calls) **are** supported as of Iter 7 — see below.
|
||||
- No effect handlers — only the built-in IO and Diverge ops.
|
||||
- No refinements / SMT escalation.
|
||||
- No cross-module ADTs. ADTs are local to a module; ctor names must be
|
||||
@@ -253,6 +255,14 @@ What **is** supported (and used as the smoke test for the pipeline):
|
||||
pattern are restricted to `Var` / `Wild`.
|
||||
- **Imports + qualified cross-module references** via dotted names
|
||||
(Iter 5).
|
||||
- **First-class function references** (Iter 7). A top-level fn name (or
|
||||
qualified `prefix.def`) used as a `Term::Var` is a fn-pointer value of
|
||||
AILang type `Type::Fn { ... }` and LLVM type `ptr`. Fn-typed parameters
|
||||
may be called directly as `f(args)`. No capture, no lambdas — the only
|
||||
way to construct a fn-value is to refer to a top-level def.
|
||||
|
||||
Pipeline regression smoke test: `examples/sum.ail.json` produces a
|
||||
binary that prints 55.
|
||||
Pipeline regression smoke tests:
|
||||
|
||||
- `examples/sum.ail.json` → prints 55 (recursion, arithmetic).
|
||||
- `examples/list.ail.json` → prints 42 (ADTs + match).
|
||||
- `examples/hof.ail.json` → prints 42 (first-class fn-refs, indirect call).
|
||||
|
||||
@@ -430,3 +430,92 @@ stage, closure conversion in lowering, and a heap-aware ABI. The
|
||||
multi-diag refactor in Iter 6 was scoped intentionally minimal — when
|
||||
TIR lands, intra-def diagnostics become structurally cheap and Task #20
|
||||
gets revisited.
|
||||
|
||||
## 2026-05-07 — Iter 7 done: first-class function references (no capture)
|
||||
|
||||
Iter 6 outlined Iter 7 as "closures + HOFs + TIR". KISS course-correct
|
||||
on inspection: that bundle had three independent things in it, and the
|
||||
HOF use-cases (passing functions around, calling through fn-typed
|
||||
parameters) need none of TIR or capture. Splitting paid off — what
|
||||
landed here is ~120 LOC of codegen, no TIR, no heap, no ABI churn.
|
||||
Closures with capture stay queued for Iter 8 (where TIR is the
|
||||
correct precondition).
|
||||
|
||||
**What works now:**
|
||||
- Top-level fn name (or qualified `prefix.def`) used as a value yields
|
||||
an LLVM fn-pointer (`@ail_<m>_<def>`, type `ptr`).
|
||||
- Fn-typed parameters can be called as `f(args)` — the body emits an
|
||||
indirect `call <ret> (<param-tys>) %f(...)`.
|
||||
- Pass through `let`: `let g = inc in g(x)` works (the local just
|
||||
aliases the global SSA, the sidetable lookup still hits).
|
||||
- Pass to another fn: `apply(inc, 41) == 42` — see
|
||||
`examples/hof.ail.json`, exercised end-to-end.
|
||||
|
||||
**What does not (yet) work — by design:**
|
||||
- No anonymous lambdas. The only fn-value source is a top-level def
|
||||
reference.
|
||||
- No capture. A fn-value is always a constant pointer to a top-level
|
||||
def; there is no environment to allocate.
|
||||
- Both deferred to Iter 8 where they share the TIR + closure-conversion
|
||||
preconditions.
|
||||
|
||||
**Implementation, in order of where the rubber meets the road:**
|
||||
|
||||
1. `llvm_type` learned `Type::Fn { .. } -> "ptr"`. The actual signature
|
||||
travels separately. New helper `fn_sig_from_type` lifts an AILang
|
||||
fn-type into an `FnSig` (LLVM types only).
|
||||
2. `Emitter` got a sidetable: `ssa_fn_sigs: BTreeMap<String, FnSig>`,
|
||||
keyed by SSA value (or `@global`). It's reset per function body.
|
||||
3. At `emit_fn` entry, every fn-typed parameter registers
|
||||
`(%arg_<name>, sig)` in the sidetable.
|
||||
4. `lower_term(Term::Var)` now falls through to a top-level fn lookup
|
||||
(`resolve_top_level_fn`) when the name isn't a local. The returned
|
||||
SSA is the global symbol; the sidetable gets the sig.
|
||||
5. `lower_term(Term::App)` dispatches:
|
||||
- if callee is a `Var` AND not shadowed AND statically known
|
||||
(`is_static_callee` covers builtin operators, qualified
|
||||
`prefix.def`, current-module fns), keep the existing direct
|
||||
`lower_app` path — no extra indirection in the IR;
|
||||
- otherwise lower the callee, expect type `ptr`, look up the sig in
|
||||
the sidetable, emit `emit_indirect_call`.
|
||||
6. `Term::If` propagates the sig to its phi SSA when both branches are
|
||||
fn-pointers with matching sigs (cheap two-line copy; no separate
|
||||
test, falls out of the `apply`-on-conditional pattern).
|
||||
|
||||
**Why no typechecker change?** The typechecker already accepted
|
||||
fn-typed locals (`Term::Var` against `env.globals`, App via `synth(callee)`
|
||||
unifying with `Type::Fn`). The only blocker was `MVP: callee must be a
|
||||
variable` in codegen.
|
||||
|
||||
**Tests:** 48 green (previously 47).
|
||||
|
||||
- `crates/ail/tests/e2e.rs::higher_order_apply_inc` builds and runs
|
||||
`examples/hof.ail.json`, asserts the binary prints `42`.
|
||||
- Existing tests unchanged (incl. snapshot tests around the IR
|
||||
emission for `sum`, `list`, `max3`).
|
||||
|
||||
**Architecture self-check:**
|
||||
|
||||
- *Would I use this language now?* Yes for `apply`-style and "pass a
|
||||
predicate" patterns. Still no for capturing closures (`let n = 3 in
|
||||
map(\x -> x + n, xs)`-equivalent), but the ergonomic gap shrank.
|
||||
- *Consistency:* DESIGN.md "What is not (yet) supported" rewritten in
|
||||
the same edit; first-class fn-refs now have a positive bullet, the
|
||||
closures bullet is precise about what it means (no capture, no
|
||||
lambdas).
|
||||
- *Visualisation:* `ail describe`/`manifest` already render fn-typed
|
||||
params correctly via the existing `pretty::type_to_string`
|
||||
(`((Int) -> Int, Int) -> Int`). No tooling change required.
|
||||
- *KISS:* every alternative I considered (full `LocalType` enum,
|
||||
swapping `(String, String)` returns to a typed wrapper, lifting
|
||||
lambdas to defs as syntactic sugar) was strictly more code than the
|
||||
sidetable approach, with no expressivity gain.
|
||||
|
||||
**Plan iteration 8:**
|
||||
|
||||
Closures with capture, anonymous lambdas, the typed IR (TIR) layer,
|
||||
closure conversion in lowering. Now that we have indirect calls
|
||||
working, the main delta is: a fn-value also needs an environment
|
||||
pointer, the sidetable becomes per-value (heap-allocated), and the
|
||||
calling convention shifts to `(env_ptr, args...)`. Touches every
|
||||
existing call path — that's why it gets its own iteration.
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
{
|
||||
"schema": "ailang/v0",
|
||||
"name": "hof",
|
||||
"imports": [],
|
||||
"defs": [
|
||||
{
|
||||
"kind": "fn",
|
||||
"name": "inc",
|
||||
"type": {
|
||||
"k": "fn",
|
||||
"params": [{ "k": "con", "name": "Int" }],
|
||||
"ret": { "k": "con", "name": "Int" },
|
||||
"effects": []
|
||||
},
|
||||
"params": ["x"],
|
||||
"doc": "increment by one",
|
||||
"body": {
|
||||
"t": "app",
|
||||
"fn": { "t": "var", "name": "+" },
|
||||
"args": [
|
||||
{ "t": "var", "name": "x" },
|
||||
{ "t": "lit", "lit": { "kind": "int", "value": 1 } }
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"kind": "fn",
|
||||
"name": "apply",
|
||||
"type": {
|
||||
"k": "fn",
|
||||
"params": [
|
||||
{
|
||||
"k": "fn",
|
||||
"params": [{ "k": "con", "name": "Int" }],
|
||||
"ret": { "k": "con", "name": "Int" },
|
||||
"effects": []
|
||||
},
|
||||
{ "k": "con", "name": "Int" }
|
||||
],
|
||||
"ret": { "k": "con", "name": "Int" },
|
||||
"effects": []
|
||||
},
|
||||
"params": ["f", "x"],
|
||||
"doc": "apply a fn-of-Int to an Int",
|
||||
"body": {
|
||||
"t": "app",
|
||||
"fn": { "t": "var", "name": "f" },
|
||||
"args": [{ "t": "var", "name": "x" }]
|
||||
}
|
||||
},
|
||||
{
|
||||
"kind": "fn",
|
||||
"name": "main",
|
||||
"type": {
|
||||
"k": "fn",
|
||||
"params": [],
|
||||
"ret": { "k": "con", "name": "Unit" },
|
||||
"effects": ["IO"]
|
||||
},
|
||||
"params": [],
|
||||
"body": {
|
||||
"t": "do",
|
||||
"op": "io/print_int",
|
||||
"args": [
|
||||
{
|
||||
"t": "app",
|
||||
"fn": { "t": "var", "name": "apply" },
|
||||
"args": [
|
||||
{ "t": "var", "name": "inc" },
|
||||
{ "t": "lit", "lit": { "kind": "int", "value": 41 } }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user