From 741016c253ab22f302002db2b85873f04b8a4a95 Mon Sep 17 00:00:00 2001 From: Cam Pedersen Date: Tue, 7 Jul 2026 00:17:26 -0500 Subject: [PATCH] Add shared builtin registry + fill stdlib gaps on both backends Registry (crates/loon-lang/src/builtins.rs): one table of (name, signature, doc, arity, typing) that the checker's initial environment, loon card, and the conformance suite derive from. tests/builtin_registry.rs asserts every entry is wired in the checker, the legacy interpreter, and EIR lowering, plus differential output-parity tests between the two backends. Gap fills (issues #18 #19 #23 #24 #25 #26): - math: sqrt/pow now typecheck on Int via a Num trait bound; floor, ceil, round, sin/cos/tan/asin/acos/atan/atan2, log, log10, exp, pi, e on both backends - parse-int / parse-float returning Option - capitalize, pad-left, pad-right, repeat - index-of on vectors (interp was strings-only; VM too) - reduce as a true alias of fold (VM previously mis-lowered reduce into the 2-arg map/filter group and returned unit); vals/values aliased both ways - interp gains slice/concat; VM gains map?, vec?, name, type-of, remove - Rand effect (rand, rand-int, seed): nondeterminism via effects, recorded for record/replay, shared SplitMix64 so seeded runs match across backends - hex/octal/binary integer literals (0xFF, 0o17, 0b1010, _ separators) loon card now appends a Builtins section generated from the registry. New prior-alignment corpus entries cover reduce, math, parse-int, string helpers, vector index-of, and radix literals. Co-Authored-By: Claude Fable 5 --- crates/loon-cli/src/main.rs | 9 +- .../tests/prior-alignment/c-hex-literals.oo | 14 + .../prior-alignment/clojure-reduce-alias.oo | 10 + .../prior-alignment/js-index-of-vector.oo | 10 + .../prior-alignment/js-string-helpers.oo | 14 + .../prior-alignment/python-math-builtins.oo | 16 + .../tests/prior-alignment/python-parse-int.oo | 14 + crates/loon-lang/src/builtins.rs | 751 ++++++++++++++++++ crates/loon-lang/src/check/mod.rs | 250 ++++-- crates/loon-lang/src/effects/mod.rs | 47 ++ crates/loon-lang/src/eir/lower.rs | 54 +- crates/loon-lang/src/eir/mod.rs | 35 +- crates/loon-lang/src/eir/replay.rs | 2 +- crates/loon-lang/src/eir/vm.rs | 293 ++++++- crates/loon-lang/src/interp/builtins.rs | 177 ++++- crates/loon-lang/src/interp/mod.rs | 38 + crates/loon-lang/src/lib.rs | 1 + crates/loon-lang/src/parser/mod.rs | 23 + crates/loon-lang/src/syntax/tokens.rs | 6 + crates/loon-lang/tests/builtin_registry.rs | 223 ++++++ 20 files changed, 1918 insertions(+), 69 deletions(-) create mode 100644 crates/loon-cli/tests/prior-alignment/c-hex-literals.oo create mode 100644 crates/loon-cli/tests/prior-alignment/clojure-reduce-alias.oo create mode 100644 crates/loon-cli/tests/prior-alignment/js-index-of-vector.oo create mode 100644 crates/loon-cli/tests/prior-alignment/js-string-helpers.oo create mode 100644 crates/loon-cli/tests/prior-alignment/python-math-builtins.oo create mode 100644 crates/loon-cli/tests/prior-alignment/python-parse-int.oo create mode 100644 crates/loon-lang/src/builtins.rs create mode 100644 crates/loon-lang/tests/builtin_registry.rs diff --git a/crates/loon-cli/src/main.rs b/crates/loon-cli/src/main.rs index 35474de..73f2c09 100644 --- a/crates/loon-cli/src/main.rs +++ b/crates/loon-cli/src/main.rs @@ -1001,7 +1001,14 @@ fn check_file(path: &PathBuf, json: bool) { fn print_card(json: bool) { const CARD: &str = include_str!("card.md"); let version = env!("GIT_VERSION"); - let card = CARD.replace("{{VERSION}}", version); + let mut card = CARD.replace("{{VERSION}}", version); + + // Builtins section is generated from the shared registry so the card + // can never drift from what the checker/interp/VM actually implement. + card.push_str("\n## Builtins\n\n"); + for b in loon_lang::builtins::BUILTINS { + card.push_str(&format!("`{}` : {} — {}\n", b.name, b.sig, b.doc)); + } if json { // Split on `## ` headings into structured sections. let mut sections = Vec::new(); diff --git a/crates/loon-cli/tests/prior-alignment/c-hex-literals.oo b/crates/loon-cli/tests/prior-alignment/c-hex-literals.oo new file mode 100644 index 0000000..85e63b3 --- /dev/null +++ b/crates/loon-cli/tests/prior-alignment/c-hex-literals.oo @@ -0,0 +1,14 @@ +; prior: C/Rust/Python/JS — `0xFF`, `0o17`, `0b1010` radix literals are +; universal; agents write them for masks and flags without thinking. +; expectation: hex/octal/binary integer literals parse (with `_` +; separators), including negative forms. +; principle: numeric literal syntax matches mainstream priors. +; expect-stdout: 255 +; expect-stdout: 15 +; expect-stdout: 10 +; expect-stdout: -16 +[fn main [] + [println 0xFF] + [println 0o17] + [println 0b1010] + [println -0x10]] diff --git a/crates/loon-cli/tests/prior-alignment/clojure-reduce-alias.oo b/crates/loon-cli/tests/prior-alignment/clojure-reduce-alias.oo new file mode 100644 index 0000000..615e3aa --- /dev/null +++ b/crates/loon-cli/tests/prior-alignment/clojure-reduce-alias.oo @@ -0,0 +1,10 @@ +; prior: Clojure/Python — agents reach for `reduce`, not `fold`, when +; accumulating over a collection. +; expectation: reduce is a first-class alias of fold with identical +; init + fn + coll semantics on every backend. +; principle: meet the agent's prior instead of teaching a synonym. +; expect-stdout: 10 +; expect-stdout: 10 +[fn main [] + [println [reduce 0 [fn [acc x] [+ acc x]] #[1 2 3 4]]] + [println [fold 0 [fn [acc x] [+ acc x]] #[1 2 3 4]]]] diff --git a/crates/loon-cli/tests/prior-alignment/js-index-of-vector.oo b/crates/loon-cli/tests/prior-alignment/js-index-of-vector.oo new file mode 100644 index 0000000..12eba41 --- /dev/null +++ b/crates/loon-cli/tests/prior-alignment/js-index-of-vector.oo @@ -0,0 +1,10 @@ +; prior: JS `[10,20,30].indexOf(20)` / Python `list.index` — finding an +; element's position in a list is assumed everywhere. +; expectation: index-of works on vectors (first equal element, -1 when +; absent), not just strings. +; principle: one lookup builtin across strings and vectors. +; expect-stdout: 1 +; expect-stdout: -1 +[fn main [] + [println [index-of #[10 20 30] 20]] + [println [index-of #[1 2] 9]]] diff --git a/crates/loon-cli/tests/prior-alignment/js-string-helpers.oo b/crates/loon-cli/tests/prior-alignment/js-string-helpers.oo new file mode 100644 index 0000000..947b125 --- /dev/null +++ b/crates/loon-cli/tests/prior-alignment/js-string-helpers.oo @@ -0,0 +1,14 @@ +; prior: JS `"ab".repeat(3)`, `padStart`, Python `str.capitalize()` — string +; utility helpers agents type without checking the docs. +; expectation: capitalize / pad-left / pad-right / repeat exist and behave +; like their JS/Python counterparts (pad takes an explicit pad string). +; principle: meet string-helper priors from mainstream languages. +; expect-stdout: Loon +; expect-stdout: 005 +; expect-stdout: ab-- +; expect-stdout: ababab +[fn main [] + [println [capitalize "loon"]] + [println [pad-left "5" 3 "0"]] + [println [pad-right "ab" 4 "-"]] + [println [repeat "ab" 3]]] diff --git a/crates/loon-cli/tests/prior-alignment/python-math-builtins.oo b/crates/loon-cli/tests/prior-alignment/python-math-builtins.oo new file mode 100644 index 0000000..35a1d44 --- /dev/null +++ b/crates/loon-cli/tests/prior-alignment/python-math-builtins.oo @@ -0,0 +1,16 @@ +; prior: Python/JS — `sqrt(4)`, `pow(2, 10)`, `floor(-1.5)` are table stakes; +; agents assume core math exists and works on integer arguments. +; expectation: sqrt/pow accept Int or Float (no "cannot unify Float with +; Int"); floor/ceil/round return integers. +; principle: numeric builtins are polymorphic over Int/Float. +; expect-stdout: 2 +; expect-stdout: 1024 +; expect-stdout: -2 +; expect-stdout: 2 +; expect-stdout: 3 +[fn main [] + [println [sqrt 4]] + [println [pow 2 10]] + [println [floor -1.5]] + [println [ceil 1.2]] + [println [round 2.5]]] diff --git a/crates/loon-cli/tests/prior-alignment/python-parse-int.oo b/crates/loon-cli/tests/prior-alignment/python-parse-int.oo new file mode 100644 index 0000000..122d956 --- /dev/null +++ b/crates/loon-cli/tests/prior-alignment/python-parse-int.oo @@ -0,0 +1,14 @@ +; prior: Python `int("42")` / JS `parseInt` — string→number conversion is +; assumed; agents also expect a safe form that signals failure. +; expectation: parse-int / parse-float return Option — Some on success, +; None on garbage — so failure is a value, not a crash. +; principle: fallible conversions return Option (agent-first, no exceptions). +; expect-stdout: 43 +; expect-stdout: no-parse +[fn main [] + [match [parse-int "42"] + [Some n] [println [+ n 1]] + None [println "no-parse"]] + [match [parse-int "not a number"] + [Some n] [println n] + None [println "no-parse"]]] diff --git a/crates/loon-lang/src/builtins.rs b/crates/loon-lang/src/builtins.rs new file mode 100644 index 0000000..606edb1 --- /dev/null +++ b/crates/loon-lang/src/builtins.rs @@ -0,0 +1,751 @@ +//! Single source of truth for the Loon builtin surface. +//! +//! Every named builtin that must exist on *all* execution surfaces — the +//! type checker's initial environment, the tree-walking interpreter, and +//! the EIR VM — has one entry here. Implementations stay per-backend, but +//! the *set* and *signatures* come from this table, so a builtin cannot +//! exist on one surface and be missing on another. `loon card` renders its +//! Builtins section from this table too. +//! +//! The conformance test in `crates/loon-lang/tests/builtin_registry.rs` +//! iterates this table and asserts coverage on every surface. Adding an +//! entry here without wiring all backends fails that test. +//! +//! Deliberately excluded: operators (`+`, `=`, …— typed as trait-bounded +//! schemes and lowered to `BinOp`), special forms (`and`/`or`/`if`), +//! effects (IO/Net/…, see `effects::EffectRegistry`), and backend-specific +//! extras (channels, `push!`, DOM, physics constants). + +/// Primitive type atoms used by monomorphic registry signatures. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Ty { + Int, + Float, + Bool, + Str, + /// Numeric parameter: checker gives it a fresh var bounded by `Num` + /// (implemented by Int and Float). + Num, + /// `Option Int` + OptionInt, + /// `Option Float` + OptionFloat, +} + +/// How the type checker derives this entry's scheme. +#[derive(Debug, Clone, Copy)] +pub enum Typing { + /// Monomorphic (or Num-bounded) signature the checker derives directly + /// from the table: params → ret. + Mono(&'static [Ty], Ty), + /// A constant value of the given type (e.g. `pi`). + Const(Ty), + /// Polymorphic or otherwise bespoke — the checker registers the scheme + /// in hand-written code, but the conformance test still asserts the + /// name is present in the initial environment. + Special, +} + +/// One builtin: name, human-readable signature, doc line, arity forms. +#[derive(Debug, Clone, Copy)] +pub struct BuiltinSpec { + pub name: &'static str, + /// Human-readable signature, shown in `loon card` and docs. + pub sig: &'static str, + /// One-line doc. + pub doc: &'static str, + /// Minimum number of arguments (0 for constants). + pub min_args: u8, + /// Maximum number of arguments; `None` = variadic. + pub max_args: Option, + pub typing: Typing, +} + +const fn f( + name: &'static str, + sig: &'static str, + doc: &'static str, + min_args: u8, + max_args: u8, + typing: Typing, +) -> BuiltinSpec { + BuiltinSpec { + name, + sig, + doc, + min_args, + max_args: Some(max_args), + typing, + } +} + +use Ty::*; +use Typing::*; + +/// The registry. Grouped roughly by domain; order is the order `loon card` +/// prints. +pub const BUILTINS: &[BuiltinSpec] = &[ + // ── Output / debug ────────────────────────────────────────────── + BuiltinSpec { + name: "println", + sig: "a → ()", + doc: "print value followed by newline", + min_args: 0, + max_args: None, + typing: Special, + }, + BuiltinSpec { + name: "print", + sig: "a → ()", + doc: "print value without newline", + min_args: 0, + max_args: None, + typing: Special, + }, + BuiltinSpec { + name: "str", + sig: "a … → Str", + doc: "convert/concatenate values to a string", + min_args: 0, + max_args: None, + typing: Special, + }, + f( + "assert-eq", + "a → a → ()", + "panic unless both values are equal", + 2, + 2, + Special, + ), + // ── Collections ───────────────────────────────────────────────── + f( + "len", + "Vec a | Map k v | Str → Int", + "number of elements / chars", + 1, + 1, + Special, + ), + f( + "get", + "Map k v → k → v | Vec a → Int → a", + "lookup by key/index", + 2, + 3, + Special, + ), + f("nth", "Vec a → Int → a", "element at index", 2, 2, Special), + f("first", "Vec a → a", "first element", 1, 1, Special), + f("last", "Vec a → a", "last element", 1, 1, Special), + f( + "range", + "Int → Int → Vec Int", + "half-open integer range", + 1, + 2, + Special, + ), + f( + "empty?", + "coll → Bool", + "true when the collection/string is empty", + 1, + 1, + Special, + ), + f( + "contains?", + "coll → a → Bool", + "membership test (collections and strings)", + 2, + 2, + Special, + ), + f( + "conj", + "Vec a → a → Vec a", + "append an element", + 2, + 2, + Special, + ), + f( + "cons", + "a → Vec a → Vec a", + "prepend an element", + 2, + 2, + Special, + ), + f( + "assoc", + "Map k v → k → v → Map k v", + "insert/replace a key", + 3, + 3, + Special, + ), + f( + "update", + "Map k v → k → (v → v) → Map k v", + "transform the value at a key", + 3, + 3, + Special, + ), + f( + "merge", + "Map k v → Map k v → Map k v", + "right-biased map merge", + 2, + 2, + Special, + ), + f( + "remove", + "Map k v → k → Map k v", + "drop a key (maps) / element (sets)", + 2, + 2, + Special, + ), + f( + "entries", + "Map k v → Vec (k, v)", + "key-value pairs", + 1, + 1, + Special, + ), + f("keys", "Map k v → Vec k", "map keys", 1, 1, Special), + f("vals", "Map k v → Vec v", "map values", 1, 1, Special), + f("values", "Map k v → Vec v", "alias of vals", 1, 1, Special), + f("sort", "Vec a → Vec a", "sort ascending", 1, 1, Special), + f( + "sort-by", + "(a → k) → Vec a → Vec a", + "sort by key function", + 2, + 2, + Special, + ), + f("reverse", "Vec a → Vec a", "reverse order", 1, 1, Special), + f( + "flatten", + "Vec (Vec a) → Vec a", + "flatten one level", + 1, + 1, + Special, + ), + f( + "zip", + "Vec a → Vec b → Vec (a, b)", + "pair up two vectors", + 2, + 2, + Special, + ), + f( + "chunk", + "Vec a → Int → Vec (Vec a)", + "split into chunks of n", + 2, + 2, + Special, + ), + f( + "take", + "Int → Vec a → Vec a", + "first n elements", + 2, + 2, + Special, + ), + f( + "drop", + "Int → Vec a → Vec a", + "all but the first n", + 2, + 2, + Special, + ), + f( + "slice", + "Vec a | Str → Int → Int → same", + "sub-range [start, end)", + 3, + 3, + Special, + ), + f( + "concat", + "Vec a → Vec a → Vec a", + "concatenate collections", + 2, + 2, + Special, + ), + f( + "find", + "(a → Bool) → Vec a → a", + "first element matching predicate", + 2, + 2, + Special, + ), + f( + "index-of", + "Vec a → a → Int | Str → Str → Int", + "index of element/substring, -1 if absent", + 2, + 2, + Special, + ), + f( + "any?", + "(a → Bool) → Vec a → Bool", + "true if any element matches", + 2, + 2, + Special, + ), + f( + "all?", + "(a → Bool) → Vec a → Bool", + "true if all elements match", + 2, + 2, + Special, + ), + f( + "map", + "(a → b) → Vec a → Vec b", + "transform each element", + 2, + 2, + Special, + ), + f( + "filter", + "(a → Bool) → Vec a → Vec a", + "keep matching elements", + 2, + 2, + Special, + ), + f( + "fold", + "b → (b → a → b) → Vec a → b", + "left fold with initial accumulator", + 2, + 3, + Special, + ), + f( + "reduce", + "b → (b → a → b) → Vec a → b", + "alias of fold", + 2, + 3, + Special, + ), + f( + "each", + "(a → ()) → Vec a → ()", + "run a function for each element", + 2, + 2, + Special, + ), + f( + "flat-map", + "(a → Vec b) → Vec a → Vec b", + "map then flatten", + 2, + 2, + Special, + ), + f( + "group-by", + "(a → k) → Vec a → Map k (Vec a)", + "group elements by key function", + 2, + 2, + Special, + ), + f( + "into-map", + "Vec (k, v) → Map k v", + "build a map from pairs", + 1, + 1, + Special, + ), + f( + "collect", + "Rx a → Vec a", + "drain a channel into a vector", + 1, + 1, + Special, + ), + f( + "sum", + "Vec Num → Num", + "sum of numeric vector", + 1, + 1, + Special, + ), + f("min", "Vec a → a", "smallest element", 1, 1, Special), + f("max", "Vec a → a", "largest element", 1, 1, Special), + // ── Strings ───────────────────────────────────────────────────── + f( + "split", + "Str → Str → Vec Str", + "split on separator", + 2, + 2, + Special, + ), + f( + "join", + "Str → Vec Str → Str", + "join with separator", + 2, + 2, + Special, + ), + f( + "trim", + "Str → Str", + "strip surrounding whitespace", + 1, + 1, + Mono(&[Str], Str), + ), + f( + "starts-with?", + "Str → Str → Bool", + "prefix test", + 2, + 2, + Mono(&[Str, Str], Bool), + ), + f( + "ends-with?", + "Str → Str → Bool", + "suffix test", + 2, + 2, + Mono(&[Str, Str], Bool), + ), + f( + "replace", + "Str → Str → Str → Str", + "replace all occurrences", + 3, + 3, + Mono(&[Str, Str, Str], Str), + ), + f( + "uppercase", + "Str → Str", + "upper-case", + 1, + 1, + Mono(&[Str], Str), + ), + f( + "lowercase", + "Str → Str", + "lower-case", + 1, + 1, + Mono(&[Str], Str), + ), + f( + "capitalize", + "Str → Str", + "upper-case the first character", + 1, + 1, + Mono(&[Str], Str), + ), + f( + "pad-left", + "Str → Int → Str → Str", + "left-pad to width with pad string", + 3, + 3, + Mono(&[Str, Int, Str], Str), + ), + f( + "pad-right", + "Str → Int → Str → Str", + "right-pad to width with pad string", + 3, + 3, + Mono(&[Str, Int, Str], Str), + ), + f( + "repeat", + "Str → Int → Str", + "repeat a string n times", + 2, + 2, + Mono(&[Str, Int], Str), + ), + f( + "char-at", + "Str → Int → Str", + "character at index", + 2, + 2, + Mono(&[Str, Int], Str), + ), + f( + "substring", + "Str → Int → Int → Str", + "substring [start, end)", + 3, + 3, + Mono(&[Str, Int, Int], Str), + ), + // ── Conversion / predicates ───────────────────────────────────── + f( + "int", + "Num | Str → Int", + "truncate to integer / parse (unparseable → ())", + 1, + 1, + Special, + ), + f( + "float", + "Num | Str → Float", + "convert to float / parse (unparseable → ())", + 1, + 1, + Special, + ), + f( + "parse-int", + "Str → Option Int", + "parse an integer, None on failure", + 1, + 1, + Mono(&[Str], OptionInt), + ), + f( + "parse-float", + "Str → Option Float", + "parse a float, None on failure", + 1, + 1, + Mono(&[Str], OptionFloat), + ), + f( + "keyword", + "Str → Keyword", + "string to keyword", + 1, + 1, + Special, + ), + f( + "keywordize-keys", + "Map Str v → Map Keyword v", + "convert string keys to keywords", + 1, + 1, + Special, + ), + f("name", "Keyword → Str", "keyword to string", 1, 1, Special), + f("type-of", "a → Str", "runtime type name", 1, 1, Special), + f( + "not", + "Bool → Bool", + "logical negation", + 1, + 1, + Mono(&[Bool], Bool), + ), + f("map?", "a → Bool", "true for maps", 1, 1, Special), + f("vec?", "a → Bool", "true for vectors", 1, 1, Special), + f( + "some?", + "a → Bool", + "false for None/(), true otherwise", + 1, + 1, + Special, + ), + f("none?", "a → Bool", "true for None/()", 1, 1, Special), + f("nil?", "a → Bool", "alias of none?", 1, 1, Special), + // ── Math ──────────────────────────────────────────────────────── + f("abs", "Num a ⇒ a → a", "absolute value", 1, 1, Special), + f( + "sqrt", + "Num → Float", + "square root", + 1, + 1, + Mono(&[Num], Float), + ), + f( + "pow", + "Num → Num → Float", + "base to the power of exponent", + 2, + 2, + Mono(&[Num, Num], Float), + ), + f( + "floor", + "Num → Int", + "round down to integer", + 1, + 1, + Mono(&[Num], Int), + ), + f( + "ceil", + "Num → Int", + "round up to integer", + 1, + 1, + Mono(&[Num], Int), + ), + f( + "round", + "Num → Int", + "round half away from zero to integer", + 1, + 1, + Mono(&[Num], Int), + ), + f( + "sin", + "Num → Float", + "sine (radians)", + 1, + 1, + Mono(&[Num], Float), + ), + f( + "cos", + "Num → Float", + "cosine (radians)", + 1, + 1, + Mono(&[Num], Float), + ), + f( + "tan", + "Num → Float", + "tangent (radians)", + 1, + 1, + Mono(&[Num], Float), + ), + f("asin", "Num → Float", "arcsine", 1, 1, Mono(&[Num], Float)), + f( + "acos", + "Num → Float", + "arccosine", + 1, + 1, + Mono(&[Num], Float), + ), + f( + "atan", + "Num → Float", + "arctangent", + 1, + 1, + Mono(&[Num], Float), + ), + f( + "atan2", + "Num → Num → Float", + "arctangent of y/x using signs", + 2, + 2, + Mono(&[Num, Num], Float), + ), + f( + "log", + "Num → Float", + "natural logarithm", + 1, + 1, + Mono(&[Num], Float), + ), + f( + "log10", + "Num → Float", + "base-10 logarithm", + 1, + 1, + Mono(&[Num], Float), + ), + f( + "exp", + "Num → Float", + "e to the power of x", + 1, + 1, + Mono(&[Num], Float), + ), + f("pi", "Float", "π ≈ 3.14159", 0, 0, Const(Float)), + f("e", "Float", "Euler's number ≈ 2.71828", 0, 0, Const(Float)), +]; + +/// Look up a builtin by name. +pub fn lookup(name: &str) -> Option<&'static BuiltinSpec> { + BUILTINS.iter().find(|b| b.name == name) +} + +/// True when the name is a registry constant (lowered to a literal). +pub fn is_const(name: &str) -> bool { + matches!( + lookup(name), + Some(BuiltinSpec { + typing: Const(_), + .. + }) + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn no_duplicate_names() { + let mut seen = std::collections::HashSet::new(); + for b in BUILTINS { + assert!(seen.insert(b.name), "duplicate registry entry: {}", b.name); + } + } + + #[test] + fn arity_forms_are_sane() { + for b in BUILTINS { + if let Some(max) = b.max_args { + assert!(b.min_args <= max, "{}: min_args > max_args", b.name); + } + if let Typing::Mono(params, _) = b.typing { + assert_eq!( + params.len(), + b.max_args.unwrap_or(b.min_args) as usize, + "{}: Mono param count must match max arity", + b.name + ); + } + } + } +} diff --git a/crates/loon-lang/src/check/mod.rs b/crates/loon-lang/src/check/mod.rs index 7f12ecd..fc5af32 100644 --- a/crates/loon-lang/src/check/mod.rs +++ b/crates/loon-lang/src/check/mod.rs @@ -141,6 +141,7 @@ impl Checker { checker.register_dom_builtins(); checker.register_prelude(); checker.register_physics_builtins(); + checker.register_registry_builtins(); checker.effect_polymorphize_builtins(); // The top-level "ambient" effect row: open so any effect can be // performed (and absorbed) at the top level of a program. @@ -695,26 +696,27 @@ impl Checker { } else { unreachable!() }; - self.env.set_global( - "fold".to_string(), - Scheme { - bounds: vec![], - vars: vec![tva, tvb], - ty: Type::Fn( - vec![ - Type::Var(tvb), - Type::Fn( - vec![Type::Var(tvb), Type::Var(tva)], - Box::new(Type::Var(tvb)), - EffectRow::pure(), - ), - Type::Con("Vec".to_string(), vec![Type::Var(tva)]), - ], - Box::new(Type::Var(tvb)), - EffectRow::pure(), - ), - }, - ); + let fold_scheme = Scheme { + bounds: vec![], + vars: vec![tva, tvb], + ty: Type::Fn( + vec![ + Type::Var(tvb), + Type::Fn( + vec![Type::Var(tvb), Type::Var(tva)], + Box::new(Type::Var(tvb)), + EffectRow::pure(), + ), + Type::Con("Vec".to_string(), vec![Type::Var(tva)]), + ], + Box::new(Type::Var(tvb)), + EffectRow::pure(), + ), + }; + // reduce is an alias of fold (Clojure/Python priors) + for name in ["fold", "reduce"] { + self.env.set_global(name.to_string(), fold_scheme.clone()); + } } // each: ∀a. (a → ()) → Vec a → () @@ -1264,21 +1266,22 @@ impl Checker { } else { unreachable!() }; - self.env.set_global( - "values".to_string(), - Scheme { - bounds: vec![], - vars: vec![tvk, tvv], - ty: Type::Fn( - vec![Type::Con( - "Map".to_string(), - vec![Type::Var(tvk), Type::Var(tvv)], - )], - Box::new(Type::Con("Vec".to_string(), vec![Type::Var(tvv)])), - EffectRow::pure(), - ), - }, - ); + let values_scheme = Scheme { + bounds: vec![], + vars: vec![tvk, tvv], + ty: Type::Fn( + vec![Type::Con( + "Map".to_string(), + vec![Type::Var(tvk), Type::Var(tvv)], + )], + Box::new(Type::Con("Vec".to_string(), vec![Type::Var(tvv)])), + EffectRow::pure(), + ), + }; + // vals is the EIR-native name; values kept as the legacy alias + for name in ["values", "vals"] { + self.env.set_global(name.to_string(), values_scheme.clone()); + } } // merge: ∀v. Map Keyword v → Map Keyword v → Map Keyword v @@ -1827,35 +1830,84 @@ impl Checker { ); } - // sqrt: Float → Float - self.env.set_global( - "sqrt".to_string(), - Scheme::mono(Type::Fn( - vec![Type::Float], - Box::new(Type::Float), - EffectRow::pure(), - )), - ); + // sqrt/pow: registered from the builtin registry (Num-bounded). - // pow: Float → Float → Float - self.env.set_global( - "pow".to_string(), - Scheme::mono(Type::Fn( - vec![Type::Float, Type::Float], - Box::new(Type::Float), - EffectRow::pure(), - )), - ); + // abs: ∀a. Num a => a → a + { + let a = self.subst.fresh(); + let tv = if let Type::Var(v) = a { + v + } else { + unreachable!() + }; + self.subst.add_constraint( + tv, + TraitBound { + trait_name: "Num".to_string(), + }, + ); + self.env.set_global( + "abs".to_string(), + Scheme { + bounds: vec![( + tv, + vec![TraitBound { + trait_name: "Num".to_string(), + }], + )], + vars: vec![tv], + ty: Type::Fn( + vec![Type::Var(tv)], + Box::new(Type::Var(tv)), + EffectRow::pure(), + ), + }, + ); + } - // abs: Float → Float - self.env.set_global( - "abs".to_string(), - Scheme::mono(Type::Fn( - vec![Type::Float], - Box::new(Type::Float), - EffectRow::pure(), - )), - ); + // slice: ∀a. a → Int → Int → a (Vec or Str; approximate) + { + let a = self.subst.fresh(); + let tv = if let Type::Var(v) = a { + v + } else { + unreachable!() + }; + self.env.set_global( + "slice".to_string(), + Scheme { + bounds: vec![], + vars: vec![tv], + ty: Type::Fn( + vec![Type::Var(tv), Type::Int, Type::Int], + Box::new(Type::Var(tv)), + EffectRow::pure(), + ), + }, + ); + } + + // concat: ∀a. a → a → a (Vec or Str; approximate) + { + let a = self.subst.fresh(); + let tv = if let Type::Var(v) = a { + v + } else { + unreachable!() + }; + self.env.set_global( + "concat".to_string(), + Scheme { + bounds: vec![], + vars: vec![tv], + ty: Type::Fn( + vec![Type::Var(tv), Type::Var(tv)], + Box::new(Type::Var(tv)), + EffectRow::pure(), + ), + }, + ); + } // first: ∀a. Vec a → a { @@ -2240,6 +2292,69 @@ impl Checker { } } + /// Register builtins whose signatures are derived from the shared + /// builtin registry (`crate::builtins::BUILTINS`). `Typing::Special` + /// entries keep bespoke polymorphic schemes in `register_builtins`; + /// the conformance test asserts those names are present too. + fn register_registry_builtins(&mut self) { + use crate::builtins::{Ty as RTy, Typing, BUILTINS}; + + fn conv( + checker: &mut Checker, + t: RTy, + bounds: &mut Vec<(TypeVar, Vec)>, + ) -> Type { + match t { + RTy::Int => Type::Int, + RTy::Float => Type::Float, + RTy::Bool => Type::Bool, + RTy::Str => Type::Str, + RTy::OptionInt => Type::Con("Option".to_string(), vec![Type::Int]), + RTy::OptionFloat => Type::Con("Option".to_string(), vec![Type::Float]), + RTy::Num => { + let v = checker.subst.fresh(); + let tv = if let Type::Var(v) = v { + v + } else { + unreachable!() + }; + let bound = TraitBound { + trait_name: "Num".to_string(), + }; + checker.subst.add_constraint(tv, bound.clone()); + bounds.push((tv, vec![bound])); + Type::Var(tv) + } + } + } + + for spec in BUILTINS { + match spec.typing { + Typing::Special => {} + Typing::Const(ty) => { + let mut bounds = Vec::new(); + let t = conv(self, ty, &mut bounds); + self.env.set_global(spec.name.to_string(), Scheme::mono(t)); + } + Typing::Mono(params, ret) => { + let mut bounds = Vec::new(); + let param_tys: Vec = + params.iter().map(|t| conv(self, *t, &mut bounds)).collect(); + let ret_ty = conv(self, ret, &mut bounds); + let vars = bounds.iter().map(|(v, _)| *v).collect(); + self.env.set_global( + spec.name.to_string(), + Scheme { + bounds, + vars, + ty: Type::Fn(param_tys, Box::new(ret_ty), EffectRow::pure()), + }, + ); + } + } + } + } + fn register_prelude(&mut self) { // Parse and check the prelude to register Option/Result types if let Ok(exprs) = crate::parser::parse(crate::prelude::PRELUDE) { @@ -2307,6 +2422,15 @@ impl Checker { }, ); + self.traits.insert( + "Num".to_string(), + TraitDecl { + name: "Num".to_string(), + type_params: vec![], + methods: vec![], + }, + ); + // Register primitive trait impls let empty = std::collections::HashMap::new(); for ty in ["Int", "Float"] { @@ -2314,6 +2438,8 @@ impl Checker { .insert(("Add".to_string(), ty.to_string()), empty.clone()); self.trait_impls .insert(("Ord".to_string(), ty.to_string()), empty.clone()); + self.trait_impls + .insert(("Num".to_string(), ty.to_string()), empty.clone()); } for ty in ["Int", "Float", "Bool", "String", "Keyword"] { self.trait_impls diff --git a/crates/loon-lang/src/effects/mod.rs b/crates/loon-lang/src/effects/mod.rs index 4b8b4dd..f49a9b4 100644 --- a/crates/loon-lang/src/effects/mod.rs +++ b/crates/loon-lang/src/effects/mod.rs @@ -113,6 +113,17 @@ impl EffectRegistry { op("sse-broadcast", &["event", "data"]), ], }); + // Rand effect — all randomness flows through an effect (never a pure + // builtin) so record/replay stays deterministic and tests can handle + // it with canned values. + reg.register(EffectDecl { + name: "Rand".to_string(), + operations: vec![ + typed_op("rand", &[], "Float"), + typed_op("rand-int", &["lo", "hi"], "Int"), + op("seed", &["n"]), + ], + }); reg.register(EffectDecl { name: "Embed".to_string(), operations: vec![op("encode", &["text"])], @@ -178,6 +189,42 @@ impl EffectRegistry { } } +/// SplitMix64 step — the shared PRNG behind the `Rand` builtin effect. +/// Both backends use this exact generator so a seeded program produces +/// identical values under the interpreter and the EIR VM. +pub fn splitmix_next(state: &mut u64) -> u64 { + *state = state.wrapping_add(0x9E37_79B9_7F4A_7C15); + let mut z = *state; + z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + z ^ (z >> 31) +} + +/// Uniform f64 in [0, 1) from a SplitMix64 state. +pub fn splitmix_f64(state: &mut u64) -> f64 { + (splitmix_next(state) >> 11) as f64 / (1u64 << 53) as f64 +} + +/// Uniform i64 in [lo, hi) from a SplitMix64 state (lo when the range is +/// empty, matching a "no throw" builtin-effect contract). +pub fn splitmix_range(state: &mut u64, lo: i64, hi: i64) -> i64 { + if hi <= lo { + return lo; + } + let span = (hi - lo) as u64; + lo + (splitmix_next(state) % span) as i64 +} + +/// A time-derived seed for unseeded `Rand` use. +pub fn entropy_seed() -> u64 { + use std::time::{SystemTime, UNIX_EPOCH}; + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos() as u64) + .unwrap_or(0x5EED) + | 1 +} + /// Represents a performed effect that needs handling #[derive(Debug, Clone)] pub struct PerformEffect { diff --git a/crates/loon-lang/src/eir/lower.rs b/crates/loon-lang/src/eir/lower.rs index 99d65ee..b2cfbb3 100644 --- a/crates/loon-lang/src/eir/lower.rs +++ b/crates/loon-lang/src/eir/lower.rs @@ -607,6 +607,12 @@ impl<'a> Lower<'a> { return r; } } + // Registry constants (pi, e) lower to float literals when not shadowed + if let Some(val) = builtin_const(name) { + let r = self.reg(); + self.emit(Op::Lit(r, Lit::Float(val), span)); + return r; + } // Check if it's a builtin — wrap as a closure for first-class use if let Some(built) = self.resolve_builtin(name) { // Create a wrapper function: [fn [x] [builtin x]] @@ -2137,6 +2143,24 @@ impl<'a> Lower<'a> { impl Lower<'_> { fn resolve_builtin(&self, name: &str) -> Option { + resolve_builtin_name(name) + } +} + +/// Registry constants lowered to literals (`pi`, `e`). Public so the +/// registry conformance test can assert VM coverage. +pub fn builtin_const(name: &str) -> Option { + match name { + "pi" => Some(std::f64::consts::PI), + "e" => Some(std::f64::consts::E), + _ => None, + } +} + +/// Map a builtin name to its EIR intrinsic tag. Public so the registry +/// conformance test can assert VM coverage of every registry entry. +pub fn resolve_builtin_name(name: &str) -> Option { + { match name { "println" => Some(Built::Println), "print" => Some(Built::Print), @@ -2150,11 +2174,13 @@ impl Lower<'_> { "range" => Some(Built::Range), "map" => Some(Built::Map), "filter" => Some(Built::Filter), - "reduce" => Some(Built::Reduce), + // reduce is an alias of fold (same init+fn+coll semantics) + "reduce" => Some(Built::Fold), "each" => Some(Built::Each), "flat-map" => Some(Built::FlatMap), "keys" => Some(Built::Keys), "vals" => Some(Built::Vals), + "values" => Some(Built::Vals), "nth" => Some(Built::Nth), "take" => Some(Built::Take), "drop" => Some(Built::Drop), @@ -2206,6 +2232,32 @@ impl Lower<'_> { "some?" => Some(Built::SomeP), "none?" => Some(Built::NoneP), "nil?" => Some(Built::NoneP), + "map?" => Some(Built::MapP), + "vec?" => Some(Built::VecP), + "name" => Some(Built::Name), + "type-of" => Some(Built::TypeOf), + "remove" => Some(Built::Remove), + "sqrt" => Some(Built::Sqrt), + "pow" => Some(Built::Pow), + "floor" => Some(Built::Floor), + "ceil" => Some(Built::Ceil), + "round" => Some(Built::Round), + "sin" => Some(Built::Sin), + "cos" => Some(Built::Cos), + "tan" => Some(Built::Tan), + "asin" => Some(Built::Asin), + "acos" => Some(Built::Acos), + "atan" => Some(Built::Atan), + "atan2" => Some(Built::Atan2), + "log" => Some(Built::Log), + "log10" => Some(Built::Log10), + "exp" => Some(Built::Exp), + "parse-int" => Some(Built::ParseInt), + "parse-float" => Some(Built::ParseFloat), + "capitalize" => Some(Built::Capitalize), + "pad-left" => Some(Built::PadLeft), + "pad-right" => Some(Built::PadRight), + "repeat" => Some(Built::Repeat), _ => None, } } diff --git a/crates/loon-lang/src/eir/mod.rs b/crates/loon-lang/src/eir/mod.rs index 15b35d4..3d1fffe 100644 --- a/crates/loon-lang/src/eir/mod.rs +++ b/crates/loon-lang/src/eir/mod.rs @@ -316,7 +316,6 @@ pub enum Built { Range, Map, Filter, - Reduce, Each, FlatMap, Keys, @@ -373,4 +372,38 @@ pub enum Built { SomeP, /// `none?` — true for None/unit (complement of `some?`). NoneP, + /// `map?` — true for maps. + MapP, + /// `vec?` — true for vectors. + VecP, + /// `name` — keyword to string. + Name, + /// `type-of` — runtime type name as a string. + TypeOf, + /// `remove` — drop a key from a map / element from a set. + Remove, + // Math (issue #19) + Sqrt, + Pow, + Floor, + Ceil, + Round, + Sin, + Cos, + Tan, + Asin, + Acos, + Atan, + Atan2, + Log, + Log10, + Exp, + // String → number parsing (issue #18) + ParseInt, + ParseFloat, + // String helpers (issue #23) + Capitalize, + PadLeft, + PadRight, + Repeat, } diff --git a/crates/loon-lang/src/eir/replay.rs b/crates/loon-lang/src/eir/replay.rs index bd6023f..ffd6366 100644 --- a/crates/loon-lang/src/eir/replay.rs +++ b/crates/loon-lang/src/eir/replay.rs @@ -181,7 +181,7 @@ impl TraceEntry { /// handled by an in-language `handle` never reach this path at all. pub fn is_recorded_op(effect: &str, op: &str) -> bool { match effect { - "Net" | "Env" | "Process" => true, + "Net" | "Env" | "Process" | "Rand" => true, "IO" => !matches!(op, "parse-json" | "to-json" | "blake3"), _ => false, } diff --git a/crates/loon-lang/src/eir/vm.rs b/crates/loon-lang/src/eir/vm.rs index dab719d..3914974 100644 --- a/crates/loon-lang/src/eir/vm.rs +++ b/crates/loon-lang/src/eir/vm.rs @@ -62,6 +62,16 @@ impl OrdMap { .iter() .map(move |k| (k, self.map.get(k).unwrap())) } + /// Remove a key (and its slot in the insertion order), if present. + fn remove(&mut self, k: &Val) -> Option { + let prev = self.map.remove(k); + if prev.is_some() { + if let Some(pos) = self.order.iter().position(|x| x == k) { + self.order.remove(pos); + } + } + prev + } /// Left-biased union (values already in `self` win), preserving `self`'s /// order and appending `other`'s new keys in their order. fn union(&self, other: Self) -> Self { @@ -261,6 +271,9 @@ pub struct Vm { /// keys, `[keyword s]`) that are not in the module's compile-time string /// table. A runtime symbol's id is `module.strings.len() + index`. runtime_syms: Vec, + /// SplitMix64 state for the `Rand` builtin effect; seeded lazily from + /// the clock, or explicitly via `Rand.seed`. + rand_state: Option, /// The ADT tag of the `None` constructor (latest definition wins, same /// rule as `ctor_tag`). Nullary ADTs with this tag are normalized to the /// immediate singleton `Val::NONE` at every construction site, so `None` @@ -319,6 +332,7 @@ impl Vm { recorder: None, replay: None, runtime_syms: Vec::new(), + rand_state: None, none_tag, } } @@ -1485,7 +1499,7 @@ impl Vm { Ok(self.alloc(Obj::Vec(ImVec::new()))) } } - Built::Map | Built::Filter | Built::Each | Built::Reduce => { + Built::Map | Built::Filter | Built::Each => { // Higher-order builtins: call the function for each element. // Detect collection vs function by TYPE, not position, so both // the direct form `[map coll fn]` and the pipe/thread-last form @@ -1761,6 +1775,240 @@ impl Vm { let v = args.first().copied().unwrap_or(Val::UNIT); Ok(Val::bool(v.is_none() || v.is_unit())) } + Built::MapP => { + let v = args.first().copied().unwrap_or(Val::UNIT); + Ok(Val::bool(matches!(self.get_obj(v), Some(Obj::Map(_))))) + } + Built::VecP => { + let v = args.first().copied().unwrap_or(Val::UNIT); + Ok(Val::bool(matches!(self.get_obj(v), Some(Obj::Vec(_))))) + } + Built::Name => { + // [name :kw] → "kw"; strings pass through (mirrors interp). + let v = args.first().copied().unwrap_or(Val::UNIT); + if v.is_sym() { + match self.sym_name(v.as_sym() as usize).map(|s| s.to_string()) { + Some(s) => Ok(self.alloc_str(s)), + None => Ok(Val::UNIT), + } + } else if matches!(self.get_obj(v), Some(Obj::Str(_))) { + Ok(v) + } else { + Ok(Val::UNIT) + } + } + Built::TypeOf => { + // Runtime type name; matches the interpreter's type-of. + let v = args.first().copied().unwrap_or(Val::UNIT); + let t: String = if v.is_int() { + "Int".to_string() + } else if v.is_float() { + "Float".to_string() + } else if v.is_bool() { + "Bool".to_string() + } else if v.is_sym() { + "Keyword".to_string() + } else if v.is_none() { + "None".to_string() + } else if v.is_unit() { + "Unit".to_string() + } else { + match self.get_obj(v) { + Some(Obj::Str(_)) => "String".to_string(), + Some(Obj::Vec(_)) => "Vec".to_string(), + Some(Obj::Set(_)) => "Set".to_string(), + Some(Obj::Map(_)) => "Map".to_string(), + Some(Obj::Tuple(_)) => "Tuple".to_string(), + Some(Obj::Closure(_, _)) | Some(Obj::Continuation { .. }) => { + "Fn".to_string() + } + Some(Obj::Adt(tag, _)) => { + let tag = *tag; + self.module + .ctors + .iter() + .rev() + .find(|c| c.tag == tag) + .map(|c| c.name.clone()) + .unwrap_or_else(|| "Adt".to_string()) + } + _ => "Unknown".to_string(), + } + }; + Ok(self.alloc_str(t)) + } + Built::Remove => { + // [remove map key] / [remove set elem] + let coll = args.first().copied().unwrap_or(Val::UNIT); + let key = args.get(1).copied().unwrap_or(Val::UNIT); + match self.get_obj(coll).cloned() { + Some(Obj::Map(map)) => { + let mut map = map; + map.remove(&key); + Ok(self.alloc(Obj::Map(map))) + } + Some(Obj::Set(set)) => { + let mut set = set; + set.remove(&key); + Ok(self.alloc(Obj::Set(set))) + } + _ => Ok(Val::UNIT), + } + } + // ── Math (issue #19) ─────────────────────────────────── + Built::Sqrt + | Built::Sin + | Built::Cos + | Built::Tan + | Built::Asin + | Built::Acos + | Built::Atan + | Built::Log + | Built::Log10 + | Built::Exp => { + let v = args.first().copied().unwrap_or(Val::UNIT); + let x = if v.is_int() { + v.as_int() as f64 + } else if v.is_float() { + v.as_float() + } else { + return Ok(Val::UNIT); + }; + let y = match built { + Built::Sqrt => x.sqrt(), + Built::Sin => x.sin(), + Built::Cos => x.cos(), + Built::Tan => x.tan(), + Built::Asin => x.asin(), + Built::Acos => x.acos(), + Built::Atan => x.atan(), + Built::Log => x.ln(), + Built::Log10 => x.log10(), + Built::Exp => x.exp(), + _ => unreachable!(), + }; + Ok(Val::float(y)) + } + Built::Floor | Built::Ceil | Built::Round => { + let v = args.first().copied().unwrap_or(Val::UNIT); + if v.is_int() { + return Ok(v); + } + if !v.is_float() { + return Ok(Val::UNIT); + } + let x = v.as_float(); + let y = match built { + Built::Floor => x.floor(), + Built::Ceil => x.ceil(), + Built::Round => x.round(), + _ => unreachable!(), + }; + Ok(self.safe_int(y as i64)) + } + Built::Pow => { + let num = |v: Val| -> Option { + if v.is_int() { + Some(v.as_int() as f64) + } else if v.is_float() { + Some(v.as_float()) + } else { + None + } + }; + let base = args.first().copied().and_then(num); + let exp = args.get(1).copied().and_then(num); + match (base, exp) { + (Some(b), Some(e)) => Ok(Val::float(b.powf(e))), + _ => Ok(Val::UNIT), + } + } + Built::Atan2 => { + let num = |v: Val| -> Option { + if v.is_int() { + Some(v.as_int() as f64) + } else if v.is_float() { + Some(v.as_float()) + } else { + None + } + }; + let y = args.first().copied().and_then(num); + let x = args.get(1).copied().and_then(num); + match (y, x) { + (Some(y), Some(x)) => Ok(Val::float(y.atan2(x))), + _ => Ok(Val::UNIT), + } + } + // ── String → number parsing (issue #18) ──────────────── + Built::ParseInt | Built::ParseFloat => { + let v = args.first().copied().unwrap_or(Val::UNIT); + let parsed: Option = match self.get_str(v) { + Some(s) => match built { + Built::ParseInt => s.trim().parse::().ok().map(|n| self.safe_int(n)), + _ => s.trim().parse::().ok().map(Val::float), + }, + None => None, + }; + match (parsed, self.ctor_tag("Some"), self.ctor_tag("None")) { + (Some(val), Some(some_tag), _) => Ok(self.make_adt(some_tag, vec![val])), + (None, _, Some(none_tag)) => Ok(self.make_adt(none_tag, Vec::new())), + // Option ctors unavailable (shouldn't happen): raw/unit. + (Some(val), None, _) => Ok(val), + (None, _, None) => Ok(Val::UNIT), + } + } + // ── String helpers (issue #23) ───────────────────────── + Built::Capitalize => { + let v = args.first().copied().unwrap_or(Val::UNIT); + match self.get_str(v) { + Some(s) => { + let mut chars = s.chars(); + let out = match chars.next() { + Some(c) => c.to_uppercase().collect::() + chars.as_str(), + None => String::new(), + }; + Ok(self.alloc_str(out)) + } + None => Ok(Val::UNIT), + } + } + Built::Repeat => { + let v = args.first().copied().unwrap_or(Val::UNIT); + let n = args.get(1).copied().unwrap_or(Val::int(0)); + match self.get_str(v) { + Some(s) if n.is_int() => { + let out = s.repeat(n.as_int().max(0) as usize); + Ok(self.alloc_str(out)) + } + _ => Ok(Val::UNIT), + } + } + Built::PadLeft | Built::PadRight => { + let s = args.first().copied().unwrap_or(Val::UNIT); + let w = args.get(1).copied().unwrap_or(Val::int(0)); + let p = args.get(2).copied().unwrap_or(Val::UNIT); + let (s, pad) = match ( + self.get_str(s).map(|x| x.to_string()), + self.get_str(p).map(|x| x.to_string()), + ) { + (Some(s), Some(p)) if w.is_int() => (s, p), + _ => return Ok(Val::UNIT), + }; + let len = s.chars().count(); + let want = w.as_int().max(0) as usize; + let out = if len >= want || pad.is_empty() { + s + } else { + let fill: String = pad.chars().cycle().take(want - len).collect(); + if built == Built::PadLeft { + fill + &s + } else { + s + &fill + } + }; + Ok(self.alloc_str(out)) + } Built::Keys => { let m = args.first().copied().unwrap_or(Val::UNIT); match self.get_obj(m).cloned() { @@ -2124,9 +2372,17 @@ impl Vm { } } Built::IndexOf => { - // [index-of str substr] → first index or -1 + // [index-of str substr] / [index-of vec elem] → first index or -1 let s = args.first().copied().unwrap_or(Val::UNIT); let sub = args.get(1).copied().unwrap_or(Val::UNIT); + if let Some(Obj::Vec(items)) = self.get_obj(s) { + let idx = items + .iter() + .position(|x| self.val_eq(*x, sub)) + .map(|i| i as i64) + .unwrap_or(-1); + return Ok(Val::int(idx)); + } match ( self.get_str(s).map(|s| s.to_string()), self.get_str(sub).map(|s| s.to_string()), @@ -2732,6 +2988,39 @@ impl Vm { self.safe_int(ms) } ("IO", "uuid") => self.alloc_str(gen_uuid_v4()), + ("Rand", "seed") => { + let n = args.first().copied().unwrap_or(Val::int(0)); + self.rand_state = Some(if n.is_int() { + n.as_int() as u64 + } else if n.is_float() { + n.as_float().to_bits() + } else { + 0 + }); + Val::UNIT + } + ("Rand", "rand") => { + let mut state = self.rand_state.unwrap_or_else(crate::effects::entropy_seed); + let x = crate::effects::splitmix_f64(&mut state); + self.rand_state = Some(state); + Val::float(x) + } + ("Rand", "rand-int") => { + let lo = args + .first() + .copied() + .filter(|v| v.is_int()) + .map(Val::as_int); + let hi = args.get(1).copied().filter(|v| v.is_int()).map(Val::as_int); + let (lo, hi) = match (lo, hi) { + (Some(lo), Some(hi)) => (lo, hi), + _ => return Ok(Val::UNIT), + }; + let mut state = self.rand_state.unwrap_or_else(crate::effects::entropy_seed); + let n = crate::effects::splitmix_range(&mut state, lo, hi); + self.rand_state = Some(state); + self.safe_int(n) + } ("IO", "parse-json") => { let text = args .first() diff --git a/crates/loon-lang/src/interp/builtins.rs b/crates/loon-lang/src/interp/builtins.rs index b0ffb06..a65b0c7 100644 --- a/crates/loon-lang/src/interp/builtins.rs +++ b/crates/loon-lang/src/interp/builtins.rs @@ -1186,7 +1186,14 @@ pub fn register_builtins(env: &mut Env) { Some(pos) => Ok(Value::Int(pos as i64)), None => Ok(Value::Int(-1)), }, - _ => Err(err("index-of requires two strings")), + // Vectors: index of the first equal element, -1 if absent (#25) + (Value::Vec(v), needle) => Ok(Value::Int( + v.iter() + .position(|x| x == needle) + .map(|p| p as i64) + .unwrap_or(-1), + )), + _ => Err(err("index-of requires a string or vector haystack")), } }); @@ -1350,6 +1357,167 @@ pub fn register_builtins(env: &mut Env) { Ok(Value::Float(base.powf(exp))) }); + // Core math (issue #19). + macro_rules! math_to_float { + ($name:expr, $f:expr) => { + builtin!(env, $name, |_, args: &[Value]| { + let x = match &args[0] { + Value::Float(f) => *f, + Value::Int(n) => *n as f64, + _ => return Err(err(concat!($name, " requires a number"))), + }; + let g: fn(f64) -> f64 = $f; + Ok(Value::Float(g(x))) + }); + }; + } + math_to_float!("sin", f64::sin); + math_to_float!("cos", f64::cos); + math_to_float!("tan", f64::tan); + math_to_float!("asin", f64::asin); + math_to_float!("acos", f64::acos); + math_to_float!("atan", f64::atan); + math_to_float!("log", f64::ln); + math_to_float!("log10", f64::log10); + math_to_float!("exp", f64::exp); + + macro_rules! math_to_int { + ($name:expr, $f:expr) => { + builtin!(env, $name, |_, args: &[Value]| { + match &args[0] { + Value::Int(n) => Ok(Value::Int(*n)), + Value::Float(x) => { + let g: fn(f64) -> f64 = $f; + Ok(Value::Int(g(*x) as i64)) + } + _ => Err(err(concat!($name, " requires a number"))), + } + }); + }; + } + math_to_int!("floor", f64::floor); + math_to_int!("ceil", f64::ceil); + math_to_int!("round", f64::round); + + builtin!(env, "atan2", |_, args: &[Value]| { + let num = |v: &Value| match v { + Value::Float(f) => Ok(*f), + Value::Int(n) => Ok(*n as f64), + _ => Err(err("atan2 requires numbers")), + }; + Ok(Value::Float(num(&args[0])?.atan2(num(&args[1])?))) + }); + + env.set("pi".to_string(), Value::Float(std::f64::consts::PI)); + env.set("e".to_string(), Value::Float(std::f64::consts::E)); + + // String → number parsing (issue #18): Option-returning, unlike int/float. + builtin!(env, "parse-int", |_, args: &[Value]| { + match &args[0] { + Value::Str(s) => Ok(match s.trim().parse::() { + Ok(n) => Value::Adt("Some".to_string(), vec![Value::Int(n)]), + Err(_) => Value::Adt("None".to_string(), vec![]), + }), + _ => Err(err("parse-int requires a string")), + } + }); + builtin!(env, "parse-float", |_, args: &[Value]| { + match &args[0] { + Value::Str(s) => Ok(match s.trim().parse::() { + Ok(n) => Value::Adt("Some".to_string(), vec![Value::Float(n)]), + Err(_) => Value::Adt("None".to_string(), vec![]), + }), + _ => Err(err("parse-float requires a string")), + } + }); + + // String helpers (issue #23). + builtin!(env, "capitalize", |_, args: &[Value]| { + match &args[0] { + Value::Str(s) => { + let mut chars = s.chars(); + let out = match chars.next() { + Some(c) => c.to_uppercase().collect::() + chars.as_str(), + None => String::new(), + }; + Ok(Value::Str(out.into())) + } + _ => Err(err("capitalize requires a string")), + } + }); + builtin!(env, "repeat", |_, args: &[Value]| { + match (&args[0], &args[1]) { + (Value::Str(s), Value::Int(n)) => Ok(Value::Str(s.repeat((*n).max(0) as usize).into())), + _ => Err(err("repeat requires a string and a count")), + } + }); + fn pad(s: &str, width: i64, padding: &str, left: bool) -> String { + let len = s.chars().count(); + let want = width.max(0) as usize; + if len >= want || padding.is_empty() { + return s.to_string(); + } + let fill: String = padding.chars().cycle().take(want - len).collect(); + if left { + fill + s + } else { + s.to_string() + &fill + } + } + builtin!(env, "pad-left", |_, args: &[Value]| { + match (&args[0], &args[1], &args[2]) { + (Value::Str(s), Value::Int(w), Value::Str(p)) => { + Ok(Value::Str(pad(s, *w, p, true).into())) + } + _ => Err(err("pad-left requires a string, width, and pad string")), + } + }); + builtin!(env, "pad-right", |_, args: &[Value]| { + match (&args[0], &args[1], &args[2]) { + (Value::Str(s), Value::Int(w), Value::Str(p)) => { + Ok(Value::Str(pad(s, *w, p, false).into())) + } + _ => Err(err("pad-right requires a string, width, and pad string")), + } + }); + + // slice: [slice coll start end) on vectors and strings. + builtin!(env, "slice", |_, args: &[Value]| { + let (start, end) = match (&args[1], &args[2]) { + (Value::Int(s), Value::Int(e)) => (*s, *e), + _ => return Err(err("slice requires integer start and end")), + }; + match &args[0] { + Value::Vec(v) => { + let len = v.len() as i64; + let s = start.clamp(0, len) as usize; + let e = end.clamp(start.clamp(0, len), len) as usize; + Ok(Value::Vec(v.clone().slice(s..e))) + } + Value::Str(st) => { + let chars: Vec = st.chars().collect(); + let len = chars.len() as i64; + let s = start.clamp(0, len) as usize; + let e = end.clamp(start.clamp(0, len), len) as usize; + Ok(Value::Str(chars[s..e].iter().collect::().into())) + } + _ => Err(err("slice requires a vector or string")), + } + }); + + // concat: concatenate two vectors or strings. + builtin!(env, "concat", |_, args: &[Value]| { + match (&args[0], &args[1]) { + (Value::Vec(a), Value::Vec(b)) => { + let mut out = a.clone(); + out.append(b.clone()); + Ok(Value::Vec(out)) + } + (Value::Str(a), Value::Str(b)) => Ok(Value::Str(format!("{a}{b}").into())), + _ => Err(err("concat requires two vectors or two strings")), + } + }); + builtin!(env, "abs", |_, args: &[Value]| { match &args[0] { Value::Int(n) => Ok(Value::Int(n.abs())), @@ -1602,6 +1770,13 @@ pub fn register_builtins(env: &mut Env) { "Const.e-charge".to_string(), Value::Float(1.602_176_634e-19), ); + + // Aliases (kept in lockstep with the builtin registry). + for (alias, canonical) in [("reduce", "fold"), ("vals", "values")] { + if let Some(v) = env.get(canonical) { + env.set(alias.to_string(), v); + } + } } pub fn apply_value(func: &Value, args: &[Value]) -> IResult { diff --git a/crates/loon-lang/src/interp/mod.rs b/crates/loon-lang/src/interp/mod.rs index 4b42676..62a5059 100644 --- a/crates/loon-lang/src/interp/mod.rs +++ b/crates/loon-lang/src/interp/mod.rs @@ -103,6 +103,11 @@ pub fn err_at(msg: impl Into, span: Span) -> InterpError { e } +thread_local! { + /// SplitMix64 state for the `Rand` builtin effect (None = unseeded). + static RAND_STATE: std::cell::Cell> = const { std::cell::Cell::new(None) }; +} + /// Try to handle an effect with a built-in handler (for IO at the top level). pub(crate) fn try_builtin_handler(performed: &PerformedEffect) -> Option { // Compile-time sandbox: deny effectful ops a procedural macro did not @@ -417,6 +422,39 @@ pub(crate) fn try_builtin_handler(performed: &PerformedEffect) -> Option { + let state = match performed.args.first() { + Some(Value::Int(n)) => *n as u64, + Some(Value::Float(f)) => f.to_bits(), + _ => 0, + }; + RAND_STATE.with(|s| s.set(Some(state))); + Some(Ok(Value::Unit)) + } + ("Rand", "rand") => { + let x = RAND_STATE.with(|s| { + let mut state = s.get().unwrap_or_else(crate::effects::entropy_seed); + let x = crate::effects::splitmix_f64(&mut state); + s.set(Some(state)); + x + }); + Some(Ok(Value::Float(x))) + } + ("Rand", "rand-int") => match (performed.args.first(), performed.args.get(1)) { + (Some(Value::Int(lo)), Some(Value::Int(hi))) => { + let (lo, hi) = (*lo, *hi); + let n = RAND_STATE.with(|s| { + let mut state = s.get().unwrap_or_else(crate::effects::entropy_seed); + let n = crate::effects::splitmix_range(&mut state, lo, hi); + s.set(Some(state)); + n + }); + Some(Ok(Value::Int(n))) + } + _ => Some(Err(err("Rand.rand-int requires integer lo and hi"))), + }, ("IO", "now") => { let secs = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) diff --git a/crates/loon-lang/src/lib.rs b/crates/loon-lang/src/lib.rs index 469f01a..ba70e2b 100644 --- a/crates/loon-lang/src/lib.rs +++ b/crates/loon-lang/src/lib.rs @@ -1,4 +1,5 @@ pub mod ast; +pub mod builtins; pub mod check; pub mod codegen; pub mod effects; diff --git a/crates/loon-lang/src/parser/mod.rs b/crates/loon-lang/src/parser/mod.rs index 38c2c08..9a441ee 100644 --- a/crates/loon-lang/src/parser/mod.rs +++ b/crates/loon-lang/src/parser/mod.rs @@ -124,6 +124,29 @@ impl<'a> Parser<'a> { match tok { Token::Int(s) => { + // Radix literals: 0xFF / 0o17 / 0b1010 (underscores allowed) + let (sign, body) = match s.strip_prefix('-') { + Some(rest) => (-1i64, rest), + None => (1i64, s.as_str()), + }; + let radix = match body.as_bytes().get(1) { + Some(b'x') | Some(b'X') if body.starts_with('0') => Some(16), + Some(b'o') | Some(b'O') if body.starts_with('0') => Some(8), + Some(b'b') | Some(b'B') if body.starts_with('0') => Some(2), + _ => None, + }; + if let Some(radix) = radix { + let digits: String = body[2..].chars().filter(|c| *c != '_').collect(); + // A bare "0x"/"0b" (no digits) is a unit-suffix literal + // like [unit 0 :b], not a radix literal — fall through. + if !digits.is_empty() { + let n = i64::from_str_radix(&digits, radix).map_err(|e| ParseError { + message: format!("invalid integer literal '{s}': {e}"), + span, + })?; + return Ok(Expr::new(ExprKind::Int(sign * n), span)); + } + } let suffix = extract_suffix(&s); let num_str = &s[..s.len() - suffix.len()]; let n: i64 = num_str.parse().map_err(|e| ParseError { diff --git a/crates/loon-lang/src/syntax/tokens.rs b/crates/loon-lang/src/syntax/tokens.rs index 0bc4d52..0f5b2fc 100644 --- a/crates/loon-lang/src/syntax/tokens.rs +++ b/crates/loon-lang/src/syntax/tokens.rs @@ -130,6 +130,12 @@ pub enum Token { // Literals — higher priority than Symbol #[regex(r"-?[0-9]+\.[0-9]+([eE][+-]?[0-9]+)?(f32|f64|[a-zA-Z]+)?", priority = 10, callback = |lex| lex.slice().to_string())] Float(String), + // Radix literals (issue #24): hex 0xFF, octal 0o17, binary 0b1010. + // Higher priority than the decimal rule, which would otherwise split + // e.g. "0b1010" into Int("0b") + Int("1010") via the unit-suffix group. + #[regex(r"-?0[xX][0-9a-fA-F_]+", priority = 12, callback = |lex| lex.slice().to_string())] + #[regex(r"-?0[oO][0-7_]+", priority = 12, callback = |lex| lex.slice().to_string())] + #[regex(r"-?0[bB][01_]+", priority = 12, callback = |lex| lex.slice().to_string())] #[regex(r"-?[0-9]+(i32|i64|u32|u64|[a-zA-Z]+)?", priority = 10, callback = |lex| lex.slice().to_string())] Int(String), #[token("true")] diff --git a/crates/loon-lang/tests/builtin_registry.rs b/crates/loon-lang/tests/builtin_registry.rs new file mode 100644 index 0000000..93d4928 --- /dev/null +++ b/crates/loon-lang/tests/builtin_registry.rs @@ -0,0 +1,223 @@ +//! Builtin-registry conformance suite. +//! +//! The registry (`loon_lang::builtins::BUILTINS`) is the single source of +//! truth for the builtin surface. These tests assert every entry is wired +//! on every surface — the type checker's initial environment, the legacy +//! tree-walking interpreter, and the EIR VM/lowering — so a builtin cannot +//! exist on one surface and be missing on another. + +use loon_lang::builtins::BUILTINS; +use loon_lang::check::Checker; +use loon_lang::eir::lower::{builtin_const, resolve_builtin_name}; +use loon_lang::eir::vm::eval_eir; +use loon_lang::interp::builtins::{capture_output, register_builtins}; +use loon_lang::interp::eval_program; +use loon_lang::interp::Env; +use loon_lang::parser::parse; + +#[test] +fn checker_types_every_registry_entry() { + let checker = Checker::new(); + let missing: Vec<&str> = BUILTINS + .iter() + .map(|b| b.name) + .filter(|name| checker.env.get(name).is_none()) + .collect(); + assert!( + missing.is_empty(), + "registry builtins missing from the checker's initial environment: {missing:?}" + ); +} + +#[test] +fn interp_implements_every_registry_entry() { + let mut env = Env::new(); + register_builtins(&mut env); + let missing: Vec<&str> = BUILTINS + .iter() + .map(|b| b.name) + .filter(|name| env.get(name).is_none()) + .collect(); + assert!( + missing.is_empty(), + "registry builtins missing from the interpreter: {missing:?}" + ); +} + +#[test] +fn vm_lowering_covers_every_registry_entry() { + let missing: Vec<&str> = BUILTINS + .iter() + .map(|b| b.name) + .filter(|name| resolve_builtin_name(name).is_none() && builtin_const(name).is_none()) + .collect(); + assert!( + missing.is_empty(), + "registry builtins not lowered for the EIR VM: {missing:?}" + ); +} + +// ── Differential smoke tests: both backends, identical output ────────── + +fn eir_output(src: &str) -> String { + eval_eir(src) + .unwrap_or_else(|e| panic!("EIR VM failed: {e}\n{src}")) + .output + .join("\n") +} + +fn interp_output(src: &str) -> String { + let exprs = parse(src).unwrap_or_else(|e| panic!("parse: {}\n{src}", e.message)); + let (res, out) = capture_output(|| eval_program(&exprs)); + res.unwrap_or_else(|e| panic!("interp failed: {e:?}\n{src}")); + out +} + +fn assert_parity(name: &str, src: &str, expected: &str) { + let vm = eir_output(src); + let interp = interp_output(src); + assert_eq!(vm, interp, "{name}: backend outputs diverge\n{src}"); + assert_eq!(vm, expected, "{name}: unexpected output\n{src}"); +} + +#[test] +fn math_builtins_parity() { + assert_parity("sqrt-int", "[fn main [] [println [sqrt 4]]]", "2"); + assert_parity("pow", "[fn main [] [println [pow 2 10]]]", "1024"); + assert_parity( + "floor-ceil-round", + "[fn main [] [println [floor -1.5]] [println [ceil 1.2]] [println [round 2.5]]]", + "-2\n2\n3", + ); + assert_parity("floor-int", "[fn main [] [println [floor 7]]]", "7"); + assert_parity("exp-zero", "[fn main [] [println [exp 0]]]", "1"); + assert_parity("log-e", "[fn main [] [println [log [exp 1]]]]", "1"); + assert_parity("sin-zero", "[fn main [] [println [sin 0]]]", "0"); + assert_parity("atan2", "[fn main [] [println [atan2 0 1]]]", "0"); + assert_parity( + "constants", + "[fn main [] [println [floor [* pi 100]]] [println [floor [* e 100]]]]", + "314\n271", + ); +} + +#[test] +fn parse_builtins_parity() { + assert_parity( + "parse-int", + r#"[fn main [] + [match [parse-int "42"] [Some n] [println [+ n 1]] None [println "no"]] + [match [parse-int "nope"] [Some n] [println n] None [println "no"]]]"#, + "43\nno", + ); + assert_parity( + "parse-float", + r#"[fn main [] + [match [parse-float "2.5"] [Some x] [println [* x 2.0]] None [println "no"]] + [match [parse-float "x"] [Some x] [println x] None [println "no"]]]"#, + "5\nno", + ); +} + +#[test] +fn string_helpers_parity() { + assert_parity( + "capitalize", + r#"[fn main [] [println [capitalize "loon"]] [println [capitalize ""]]]"#, + "Loon\n", + ); + assert_parity( + "pad", + r#"[fn main [] [println [pad-left "5" 3 "0"]] [println [pad-right "ab" 4 "-"]]]"#, + "005\nab--", + ); + assert_parity( + "repeat", + r#"[fn main [] [println [repeat "ab" 3]]]"#, + "ababab", + ); +} + +#[test] +fn reduce_is_fold_alias() { + assert_parity( + "reduce", + "[fn main [] [println [reduce 0 [fn [acc x] [+ acc x]] #[1 2 3 4]]]]", + "10", + ); + assert_parity( + "fold-still-works", + "[fn main [] [println [fold 0 [fn [acc x] [+ acc x]] #[1 2 3 4]]]]", + "10", + ); +} + +#[test] +fn index_of_vector_parity() { + assert_parity( + "index-of-vec", + "[fn main [] [println [index-of #[10 20 30] 20]] [println [index-of #[1 2] 9]]]", + "1\n-1", + ); +} + +#[test] +fn rand_effect_is_seeded_and_deterministic_across_backends() { + // Randomness flows through the Rand *effect* (record/replay-able), and + // both backends share the same PRNG, so a seeded program is identical + // everywhere. + let src = "[fn main [] + [Rand.seed 42] + [println [Rand.rand-int 0 1000]] + [println [Rand.rand-int 0 1000]] + [println [< [Rand.rand] 1.0]] + [println [>= [Rand.rand] 0.0]]]"; + let vm = eir_output(src); + let interp = interp_output(src); + assert_eq!(vm, interp, "seeded Rand diverges between backends\n{src}"); + let lines: Vec<&str> = vm.lines().collect(); + assert_eq!(lines.len(), 4); + assert_eq!(lines[2], "true"); + assert_eq!(lines[3], "true"); + + // Same seed → same sequence on a fresh run. + assert_eq!(vm, eir_output(src)); +} + +#[test] +fn rand_effect_can_be_handled_in_language() { + // A user handler overrides the builtin implementation — the basis for + // deterministic tests without seeding. + assert_parity( + "rand-handled", + "[fn main [] + [println [handle [Rand.rand-int 0 100] [Rand.rand-int lo hi] [resume 7]]]]", + "7", + ); +} + +#[test] +fn radix_literals_parity() { + assert_parity( + "radix", + "[fn main [] [println [+ 0xFF 0]] [println 0o17] [println 0b1010] [println -0x10] [println 0xdead_beef]]", + "255\n15\n10\n-16\n3735928559", + ); +} + +#[test] +fn sqrt_on_int_literal_typechecks() { + // Regression: [sqrt 4] used to fail with "cannot unify Float with Int". + let exprs = parse("[fn main [] [println [sqrt 4]]]").unwrap(); + let mut checker = Checker::new(); + checker.check_program(&exprs); + assert!( + checker.errors.is_empty(), + "sqrt on Int literal should typecheck: {:?}", + checker + .errors + .iter() + .map(|e| e.message()) + .collect::>() + ); +}