Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions examples/builtins-as-hof.ilo
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
-- Builtins as higher-order arguments.
-- `max`, `min`, `abs`, etc. can be passed directly to fld/map without a wrapper.

-- Fold with the `max` builtin to find the maximum.
mx xs:L n>n;fld max xs 0

-- Fold with the `min` builtin (seed must be safely high).
mn xs:L n>n;fld min xs 99999

-- Map with the `abs` builtin to take absolute values.
abs-all xs:L n>L n;map abs xs

-- vm/jit lack higher-order builtins (no FnRef dispatch)
-- engine-skip: vm
-- engine-skip: jit
-- engine-skip: cranelift

-- run: mx [3,1,4,1,5,9,2,6]
-- out: 9
-- run: mn [3,1,4,1,5,9,2,6]
-- out: 1
-- run: abs-all [-1,2,-3,4,-5]
-- out: [1, 2, 3, 4, 5]
5 changes: 5 additions & 0 deletions src/interpreter/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,11 @@ impl Env {
if self.functions.contains_key(name) {
return Ok(Value::FnRef(name.to_string()));
}
// Builtin names also resolve to FnRef so they can be passed to
// higher-order builtins (e.g. `fld max xs 0`).
if Builtin::is_builtin(name) {
return Ok(Value::FnRef(name.to_string()));
}
Err(RuntimeError::new(
"ILO-R001",
format!("undefined variable: {}", name),
Expand Down
106 changes: 106 additions & 0 deletions src/verify.rs
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,36 @@ fn is_builtin(name: &str) -> bool {
Builtin::is_builtin(name)
}

/// If `name` is a pure builtin that's safe to pass as a higher-order argument,
/// return its function type. Returns None for IO/HTTP/Map builtins, for HOFs
/// themselves (map/flt/fld/grp), and for builtins with ambiguous/polymorphic
/// types that can't be reduced to a single `Ty::Fn` signature (hd, tl, etc).
fn builtin_as_fn_ty(name: &str) -> Option<Ty> {
let n = Ty::Number;
let t = Ty::Text;
Some(match name {
// 1-arg n->n
"abs" | "flr" | "cel" | "rou" => Ty::Fn(vec![n.clone()], Box::new(n)),
// 2-arg n,n->n (suitable as fld accumulator)
"min" | "max" | "mod" => Ty::Fn(vec![n.clone(), n.clone()], Box::new(n)),
// 1-arg list->n
"sum" | "avg" => Ty::Fn(vec![Ty::List(Box::new(n.clone()))], Box::new(n)),
// 1-arg t->t
"trm" => Ty::Fn(vec![t.clone()], Box::new(t)),
// 1-arg n->t and t->R n t
"str" => Ty::Fn(vec![n], Box::new(t)),
"num" => Ty::Fn(
vec![t.clone()],
Box::new(Ty::Result(Box::new(Ty::Number), Box::new(t))),
),
// 1-arg any->t (passthrough but typed as text for json dump)
"jdmp" => Ty::Fn(vec![Ty::Unknown], Box::new(t)),
// 1-arg list->n (len of list)
"len" => Ty::Fn(vec![Ty::List(Box::new(Ty::Unknown))], Box::new(Ty::Number)),
_ => return None,
})
}

fn builtin_check_args(
name: &str,
arg_types: &[Ty],
Expand Down Expand Up @@ -1739,6 +1769,10 @@ impl VerifyContext {
// Function name used as a value — resolve to Ty::Fn
let params: Vec<Ty> = sig.params.iter().map(|(_, t)| t.clone()).collect();
Ty::Fn(params, Box::new(sig.return_type.clone()))
} else if let Some(fn_ty) = builtin_as_fn_ty(name) {
// Pure builtin used as a value (e.g. `fld max xs 0`).
// Promote to Ty::Fn so HOF args type-check.
fn_ty
} else {
let mut candidates: Vec<String> = scope
.iter()
Expand Down Expand Up @@ -6018,4 +6052,76 @@ mod tests {
.is_ok()
);
}

// ── builtin_as_fn_ty branches (Ref-fallback in verifier) ───────────────

#[test]
fn builtin_as_fn_ty_known_pure_builtins() {
// Sanity check every branch by name — guards against table drift.
for n in ["abs", "flr", "cel", "rou"] {
assert!(builtin_as_fn_ty(n).is_some(), "{n} should promote");
}
for n in ["min", "max", "mod"] {
assert!(builtin_as_fn_ty(n).is_some(), "{n} should promote");
}
for n in ["sum", "avg", "trm", "str", "num", "jdmp", "len"] {
assert!(builtin_as_fn_ty(n).is_some(), "{n} should promote");
}
// IO/HTTP/HOF/polymorphic builtins must NOT promote.
for n in [
"map", "flt", "fld", "grp", "prnt", "get", "post", "hd", "tl",
] {
assert!(
builtin_as_fn_ty(n).is_none(),
"{n} should not promote to Fn"
);
}
}

#[test]
fn verify_trm_as_hof_arg() {
// `trm :: t -> t` — pass as map fn over list of strings.
assert!(parse_and_verify("f xs:L t>L t;map trm xs").is_ok());
}

#[test]
fn verify_str_as_hof_arg() {
// `str :: n -> t` — pass as map fn.
assert!(parse_and_verify("f xs:L n>L t;map str xs").is_ok());
}

#[test]
fn verify_jdmp_as_hof_arg() {
// `jdmp :: any -> t`.
assert!(parse_and_verify("f xs:L n>L t;map jdmp xs").is_ok());
}

#[test]
fn verify_grp_with_str_key_fn() {
// grp accepts a key-fn — pass `str` builtin as the key (n -> t).
// Use type alias to avoid nested generics in the return.
let code = "alias bucket L n\nf xs:L n>M t bucket;grp str xs";
assert!(parse_and_verify(code).is_ok());
}

#[test]
fn verify_num_as_hof_arg_via_map() {
// `num :: t -> R n t` — return list of results via type alias.
let code = "alias res R n t\nf xs:L t>L res;map num xs";
assert!(parse_and_verify(code).is_ok());
}

#[test]
fn verify_len_as_hof_arg_with_named_alias() {
// `len` typed L _ -> n; alias the list type to avoid nested-paren syntax
// (which lands separately). Use a named list-of-list via type alias.
let code = "alias mat L n\nf xs:L mat>L n;map len xs";
assert!(parse_and_verify(code).is_ok());
}

#[test]
fn verify_avg_as_hof_arg_with_named_alias() {
let code = "alias vec L n\nf xs:L vec>L n;map avg xs";
assert!(parse_and_verify(code).is_ok());
}
}
136 changes: 136 additions & 0 deletions tests/regression_builtins_as_hof.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
// Regression tests for using pure builtins as higher-order function args.
//
// Background: every numerics persona writing `fld max ps 0` or `fld min ps 99999`
// hit `undefined variable 'max'` from the verifier and had to write a trivial
// wrapper like `mx2 a b>n;>=a b{ret a};+b 0`. The verifier now promotes pure
// builtin names to `Ty::Fn` when used as values, and the tree-walking
// interpreter resolves them to `Value::FnRef(name)` so `call_function`
// dispatches via the existing builtin path.
//
// VM and Cranelift JIT do not yet implement HOF dispatch at all, so those
// engines are exercised only for the verifier-error case where a HOF call
// is malformed.

use std::process::Command;

fn ilo() -> Command {
Command::new(env!("CARGO_BIN_EXE_ilo"))
}

fn write_src(name: &str, src: &str) -> std::path::PathBuf {
use std::sync::atomic::{AtomicU64, Ordering};
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_hof_{name}_{}_{n}.ilo", std::process::id()));
std::fs::write(&path, src).expect("write src");
path
}

fn run_ok(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()
}

fn run_err(engine: &str, src: &str, entry: &str) -> String {
let path = write_src(entry, src);
let out = ilo()
.arg(&path)
.arg(engine)
.arg(entry)
.output()
.expect("failed to run ilo");
let _ = std::fs::remove_file(&path);
assert!(
!out.status.success(),
"expected failure but ilo {engine} succeeded for `{src}`"
);
let mut s = String::from_utf8_lossy(&out.stderr).into_owned();
s.push_str(&String::from_utf8_lossy(&out.stdout));
s
}

// ── fld max — bare builtin as fold function ────────────────────────────────

const FLD_MAX_SRC: &str = "f xs:L n>n;fld max xs 0";

#[test]
fn fld_max_tree() {
assert_eq!(
run_ok("--run-tree", FLD_MAX_SRC, "f", &["[3,1,4,1,5,9,2,6]"]),
"9"
);
}

// ── fld min — bare builtin with high seed ──────────────────────────────────

const FLD_MIN_SRC: &str = "f xs:L n>n;fld min xs 99999";

#[test]
fn fld_min_tree() {
assert_eq!(
run_ok("--run-tree", FLD_MIN_SRC, "f", &["[3,1,4,1,5,9,2,6]"]),
"1"
);
}

// ── map abs — 1-arg numeric builtin ────────────────────────────────────────

const MAP_ABS_SRC: &str = "f xs:L n>L n;map abs xs";

#[test]
fn map_abs_tree() {
assert_eq!(
run_ok("--run-tree", MAP_ABS_SRC, "f", &["[-1,2,-3]"]),
"[1, 2, 3]"
);
}

// ── verifier: passing a builtin whose signature doesn't fit gives a clear
// diagnostic, not a runtime panic. `prnt` has no `Ty::Fn` mapping, so a
// bare `prnt` in arg position is still an "undefined variable" — which
// is the expected behaviour for IO/side-effecting builtins.

const FLD_PRNT_SRC: &str = "f xs:L n>n;fld prnt xs 0";

#[test]
fn fld_io_builtin_rejected_tree() {
let err = run_err("--run-tree", FLD_PRNT_SRC, "f");
assert!(
err.contains("undefined variable 'prnt'") || err.contains("'prnt'"),
"expected verifier error mentioning 'prnt', got: {err}"
);
}

#[test]
fn fld_io_builtin_rejected_vm() {
let err = run_err("--run-vm", FLD_PRNT_SRC, "f");
assert!(
err.contains("undefined variable 'prnt'") || err.contains("'prnt'"),
"expected verifier error mentioning 'prnt', got: {err}"
);
}

// ── wrapper-using shape still works (no regression for existing programs) ──

const WRAPPER_SRC: &str = "mx2 a:n b:n>n;>=a b{ret a};+b 0\nf xs:L n>n;fld mx2 xs 0";

#[test]
fn wrapper_still_works_tree() {
assert_eq!(
run_ok("--run-tree", WRAPPER_SRC, "f", &["[3,1,4,1,5,9,2,6]"]),
"9"
);
}
Loading