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
42 changes: 42 additions & 0 deletions docs/docs/language/control-flow.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,35 @@ does push a scope:
A loop whose condition never goes false runs until interrupted; Ctrl-C breaks
out of one at the REPL.

## Bounded Iteration: times

`times` runs the body a fixed number of times and returns null.

```text
‣ (set n 0)
‣ (times 5 (set n (+ n 1)))
‣ n
5
```

The count is evaluated **once**, on entry, so the bound is fixed however the
body mutates whatever produced it:

```lisp
(set k 3)
(times k (set k (+ k 10))) ; runs 3 times, not forever
```

A count of zero or less runs the body zero times rather than raising — a bound
that computes to empty is a no-op, not an error. A non-integer count is a type
error. Like `while`, `times` pushes no scope of its own, so a `let` in the body
binds in the enclosing frame; wrap the body in `do` when a fresh binding per
pass is wanted.

Reach for `times` when the number of passes is known up front and for `while`
when it is not. Neither allocates a sequence, so neither pays for a range that
exists only to be counted.

## Variable Binding: set and let

`set` creates a global binding. `let` creates a local binding scoped to the enclosing `do`:
Expand Down Expand Up @@ -238,7 +267,20 @@ Lambdas are auto-mapped over vectors when called directly. Use `map` for explici
‣ (scan + [1 2 3 4 5])
; => [1 3 6 10 15]

;; fold-while stops as soon as the running result fails the predicate,
;; leaving the rest of the collection untouched
‣ (fold-while (fn [acc] (< acc 100)) + 0 (til 1000))
; => 105

;; where returns indices matching a condition
‣ (where (> (til 10) 3))
; => [4 5 6 7 8 9]
```

`map`, `fold`, `scan` and `prior` all consume their whole input. `fold-while`
is the one member of the family that can stop: the accumulator is offered to
the predicate before each step, and a falsy answer ends the fold and yields the
accumulator as it stands. The test happens before the first element too, so a
predicate that is false at the start returns the initial value untouched.
Elements are pulled one at a time, so a fold that stops after three steps costs
three elements however long the collection is.
1 change: 1 addition & 0 deletions docs/docs/language/functions.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ Functions that take other functions as arguments.
| `filter` | binary | Keep elements where boolean mask is true | `(filter [1 2 3 4] (> [1 2 3 4] 2))` → `[3 4]` |
| `fold` | variadic | Reduce with function and initial value | `(fold + 0 [1 2 3])` → `6` |
| `fold-left` | variadic | Left-associative fold | `(fold-left - 10 [1 2 3])` → `4` |
| `fold-while` | variadic | Fold that stops when the accumulator fails pred | `(fold-while (fn [a] (< a 100)) + 0 (til 1000))` → `105` |
| `fold-right` | variadic | Right-associative fold | `(fold-right - 10 [1 2 3])` → `-8` |
| `scan` | variadic | Running fold (returns all intermediate results) | `(scan + (enlist 1 2 3))` → `[1 3 6]` |
| `scan-left` | variadic | Left-to-right running fold | `(scan-left + (enlist 1 2 3))` → `[1 3 6]` |
Expand Down
6 changes: 4 additions & 2 deletions docs/docs/reference/all-functions.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
|---|---|---|
| [Arithmetic](#arithmetic) (24) | [Comparison](#comparison) (7) | [Logic](#logic) (3) |
| [Aggregation](#aggregation) (25) | [Higher-Order](#higher-order) (13) | [Collection](#collection) (41) |
| [Sorting & Ordering](#sorting) (10) | [Control Flow & Special Forms](#control) (12) | [Table Operations](#table-ops) (20) |
| [Sorting & Ordering](#sorting) (10) | [Control Flow & Special Forms](#control) (13) | [Table Operations](#table-ops) (20) |
| [Query](#query) (4) | [Joins](#joins) (7) | [Pivot](#pivot) (1) |
| [String](#string-ops) (11) | [Temporal](#temporal) (3) | [Type & Introspection](#type-ops) (5) |
| [I/O & Output](#io) (12) | [System & Utility](#system) (15) | [Serialization](#serialization) (2) |
Expand Down Expand Up @@ -71,7 +71,7 @@ Generated from `src/lang/eval.c` in this checkout. The categorized reference bel
`.db.parted.get`, `.db.parted.tables`, `.db.parted.fill`, `alter`, `print`, `.sys.gc`, `.mem.objsize`, `.mem.ts`, `.sys.timeit`,
`.sys.env`, `.sys.args`, `.ipc.open`, `.ipc.handle`, `.repl.disconnect`, `.log.open`, `.log.roll`,
`.log.snapshot`, `.log.sync`, `.log.close`, `.log.purge`, `quote`, `return`, `.time.now`, `.time.timer.set`,
`fold-left`, `fold-right`, `scan-left`, `scan-right`, `del`, `.sys.build`, `.sys.mem`, `.sys.prof`,
`fold-left`, `fold-while`, `fold-right`, `scan-left`, `scan-right`, `del`, `.sys.build`, `.sys.mem`, `.sys.prof`,
`.sys.querylog`, `.sys.querylog.enable`, `modify`, `pivot`, `.sys.info`, `datoms`, `assert-fact`,
`retract-fact`, `scan-eav`, `pull`, `rule`, `query`, `dl-program`, `dl-add-edb`, `knn`, `hnsw-build`, `ann`,
`.graph.build`, `.graph.pagerank`, `.graph.connected`, `.graph.dijkstra`, `.graph.louvain`, `.graph.degree`,
Expand Down Expand Up @@ -221,6 +221,7 @@ Functions that take other functions as arguments for mapping, folding, and filte
| `pmap` | variadic | — | Parallel map (multi-threaded, returns a list) | `(pmap (fn [x] (* x x)) [1 2 3])` → `(1 4 9)` |
| `fold` | variadic | — | Reduce with function and initial value | `(fold + 0 [1 2 3])` → `6` |
| `fold-left` | variadic | — | Left-associative fold | `(fold-left - 10 [1 2 3])` → `4` |
| `fold-while` | variadic | — | Fold that stops when the accumulator fails pred | `(fold-while (fn [a] (< a 100)) + 0 (til 1000))` → `105` |
| `fold-right` | variadic | — | Right-associative fold | `(fold-right - 10 [1 2 3])` → `-8` |
| `scan` | variadic | — | Running fold (all intermediate results) | `(scan + (enlist 1 2 3))` → `[1 3 6]` |
| `scan-left` | variadic | — | Left-to-right running fold | `(scan-left + (enlist 1 2 3))` → `[1 3 6]` |
Expand Down Expand Up @@ -360,6 +361,7 @@ Special forms receive their arguments unevaluated. These are the core language p
| `if` | variadic | special | Conditional: (if cond then else) | `(if (> x 0) "pos" "neg")` |
| `do` | variadic | special | Sequential execution, returns last value | `(do (set x 1) (set y 2) (+ x y))` |
| `while` | variadic | special | Iterate while cond is truthy; returns null | `(while (> n 0) (set n (- n 1)))` |
| `times` | variadic | special | Run body exactly n times (count evaluated once); returns null | `(times 5 (set n (+ n 1)))` |
| `fn` | variadic | special | Create lambda function | `(fn [x y] (+ x y))` |
| `try` | binary | special | Error handling: (try expr handler-fn-or-fallback-value) | `(try (/ 1 0) (fn [e] 0))` |
| `raise` | unary | — | Throw an error with message | `(raise "bad input")` |
Expand Down
68 changes: 67 additions & 1 deletion src/lang/compile.c
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,7 @@ static void emit_jump_back(compiler_t *c, int32_t target) {
}

/* Cached sym IDs for special forms */
static _Thread_local int64_t sf_set = -1, sf_let = -1, sf_if = -1, sf_do = -1, sf_while = -1, sf_fn = -1, sf_self = -1, sf_try = -1, sf_return = -1, sf_null = -1;
static _Thread_local int64_t sf_set = -1, sf_let = -1, sf_if = -1, sf_do = -1, sf_while = -1, sf_times = -1, sf_fn = -1, sf_self = -1, sf_try = -1, sf_return = -1, sf_null = -1;
static _Thread_local int64_t sf_eval = -1, sf_resolve = -1;

static void init_sf_syms(void) {
Expand All @@ -225,6 +225,7 @@ static void init_sf_syms(void) {
sf_if = ray_sym_intern("if", 2);
sf_do = ray_sym_intern("do", 2);
sf_while= ray_sym_intern("while", 5);
sf_times= ray_sym_intern("times", 5);
sf_fn = ray_sym_intern("fn", 2);
sf_self = ray_sym_intern("self", 4);
sf_try = ray_sym_intern("try", 3);
Expand Down Expand Up @@ -423,6 +424,71 @@ static void compile_list(compiler_t *c, ray_t *ast) {
return;
}

/* (times n body...) — a counted loop, desugared onto the same
* backward branch `while` uses.
*
* The count is evaluated ONCE into a hidden local slot, so the
* bound is fixed on entry however the body mutates its source.
* ray_times_norm_fn type-checks it and clamps a negative bound to
* zero, which is what lets the per-pass test be a bare truthiness
* check on the counter: 0 is falsy (is_truthy), everything else is
* truthy, so the loop needs no comparison call and a negative bound
* cannot run away.
*
* Both helpers are pushed as constant-pool objects rather than
* resolved by name, so the loop's own arithmetic is invisible to
* user code and immune to an override of `-` or `>`. The counter's
* slot is likewise addressed by index, never by name, and its
* sym contains a space so no source token can collide with it —
* which is what keeps nested `times` counters apart.
*
* As with `while`, compiling the body inline is what keeps
* `return` unwinding the enclosing lambda from inside the loop. */
if (sym_id == sf_times && n >= 2) {
ray_t *norm_fn = ray_fn_unary("times norm", RAY_FN_NONE, ray_times_norm_fn);
ray_t *dec_fn = ray_fn_unary("times dec", RAY_FN_NONE, ray_times_dec_fn);
if (!norm_fn || RAY_IS_ERR(norm_fn) || !dec_fn || RAY_IS_ERR(dec_fn)) {
if (norm_fn && !RAY_IS_ERR(norm_fn)) ray_release(norm_fn);
if (dec_fn && !RAY_IS_ERR(dec_fn)) ray_release(dec_fn);
c->error = true;
return;
}
int32_t norm_idx = add_constant(c, norm_fn);
int32_t dec_idx = add_constant(c, dec_fn);
ray_release(norm_fn);
ray_release(dec_fn);
int32_t cslot = add_local(c, ray_sym_intern("times ctr", 9));
if (cslot < 0 || c->error) { c->error = true; return; }

/* counter = normalize(<count>) */
emit_const(c, norm_idx);
compile_expr(c, elems[1]);
emit(c, OP_CALL1);
emit(c, OP_STOREENV);
emit(c, (uint8_t)cslot);

int32_t top = c->code_len;
emit(c, OP_LOADENV);
emit(c, (uint8_t)cslot);
int32_t jmpf_pos = emit_jump(c, OP_JMPF);
for (int64_t i = 2; i < n; i++) {
compile_expr(c, elems[i]);
emit(c, OP_POP);
}
/* counter = counter - 1 */
emit_const(c, dec_idx);
emit(c, OP_LOADENV);
emit(c, (uint8_t)cslot);
emit(c, OP_CALL1);
emit(c, OP_STOREENV);
emit(c, (uint8_t)cslot);
emit_jump_back(c, top);
patch_jump(c, jmpf_pos);
int32_t null_idx = add_constant(c, RAY_NULL_OBJ);
emit_const(c, null_idx);
return;
}

/* (fn [params] body...) — nested lambda via dynamic eval */
if (sym_id == sf_fn && n >= 3) {
if (ast_refs_locals(c, ast)) {
Expand Down
2 changes: 1 addition & 1 deletion src/lang/env.c
Original file line number Diff line number Diff line change
Expand Up @@ -839,7 +839,7 @@ int32_t ray_env_list_user(int64_t* sym_ids, ray_t** vals, int32_t max_entries) {
/* ---- Prefix lookup ---- */

static const char* s_keywords[] = {
"def", "do", "false", "fn", "if", "let", "set", "true", "while", NULL
"def", "do", "false", "fn", "if", "let", "set", "times", "true", "while", NULL
};

/* Compare helper for qsort on const char* */
Expand Down
75 changes: 75 additions & 0 deletions src/lang/eval.c
Original file line number Diff line number Diff line change
Expand Up @@ -1967,6 +1967,79 @@ ray_t* ray_while_fn(ray_t** args, int64_t n) {
}
}

/* Read a loop count from an evaluated atom. Integers only: floats are
* rejected rather than truncated, because as_i64 on an F64 reads the bit
* pattern, and a silent (times 1.5 ...) meaning "once" hides a mistake
* either way. Returns 0 on success, or fills *err. */
static int loop_count(ray_t* x, int64_t* out, const char* who, ray_t** err) {
if (ray_is_atom(x)) {
switch (x->type) {
case -RAY_I64: case -RAY_I32: case -RAY_I16: case -RAY_U8:
*out = as_i64(x);
return 0;
default: break;
}
}
*err = ray_error("type", "%s: count must be an integer, got %s", who, ray_type_name(x->type));
return -1;
}

/* The compiled `times` loop drives its counter through these two, held
* as constant-pool objects rather than bound in the env — so they carry
* no name a user could write, and the loop's arithmetic cannot be
* changed out from under it by a `(set - ...)` style override.
*
* Normalizing once on entry (type-check, and clamp a negative bound to
* zero) is what lets the loop test the raw counter for truthiness: 0 is
* falsy, every other count is truthy, so no comparison call is needed
* per pass and a negative bound cannot run away. */
ray_t* ray_times_norm_fn(ray_t* x) {
int64_t reps = 0;
ray_t* err = NULL;
if (loop_count(x, &reps, "times", &err)) return err;
return make_i64(reps < 0 ? 0 : reps);
}

ray_t* ray_times_dec_fn(ray_t* x) {
return make_i64(as_i64(x) - 1);
}

/* (times n body...) — evaluate the body exactly n times. Receives
* unevaluated args. Always returns null, like `while`.
*
* `n` is evaluated ONCE, up front: the count is a bound fixed on entry,
* so a body that mutates whatever produced it cannot change how many
* passes remain. A count of zero or less runs the body zero times
* rather than trapping — a bound computed as empty is a no-op, not an
* error.
*
* Pushes no scope, for the reason given on ray_while_fn: `let` binds in
* the top frame only, so a per-pass frame would discard loop-carried
* state here while the compiled path kept it.
*
* The compiled form is emitted by the bytecode compiler — see compile.c. */
ray_t* ray_times_fn(ray_t** args, int64_t n) {
if (n < 1) return ray_error("domain", "times: expected at least 1 arg (count), got %lld", (long long)n);
ray_t* cnt = ray_eval(args[0]);
if (RAY_IS_ERR(cnt)) return cnt;
if (ray_is_lazy(cnt))
cnt = ray_lazy_materialize(cnt);
if (RAY_IS_ERR(cnt)) return cnt;
int64_t reps = 0;
ray_t* err = NULL;
int bad = loop_count(cnt, &reps, "times", &err);
ray_release(cnt);
if (bad) return err;
for (int64_t r = 0; r < reps; r++) {
for (int64_t i = 1; i < n; i++) {
ray_t* val = ray_eval(args[i]);
if (RAY_IS_ERR(val)) return val;
ray_release(val);
}
}
return RAY_NULL_OBJ;
}

/* ══════════════════════════════════════════
* Lambda functions
* ══════════════════════════════════════════ */
Expand Down Expand Up @@ -3191,6 +3264,7 @@ static void ray_register_builtins(void) {
register_vary("if", RAY_FN_SPECIAL_FORM, ray_cond_fn);
register_vary("do", RAY_FN_SPECIAL_FORM, ray_do_fn);
register_vary("while", RAY_FN_SPECIAL_FORM, ray_while_fn);
register_vary("times", RAY_FN_SPECIAL_FORM, ray_times_fn);
register_vary("fn", RAY_FN_SPECIAL_FORM, ray_fn);

/* Aggregation builtins */
Expand Down Expand Up @@ -3512,6 +3586,7 @@ static void ray_register_builtins(void) {

/* Directional fold/scan variants */
register_vary("fold-left", RAY_FN_NONE, ray_fold_left_fn);
register_vary("fold-while", RAY_FN_NONE, ray_fold_while_fn);
register_vary("fold-right", RAY_FN_NONE, ray_fold_right_fn);
register_vary("scan-left", RAY_FN_NONE, ray_scan_left_fn);
register_vary("scan-right", RAY_FN_NONE, ray_scan_right_fn);
Expand Down
3 changes: 3 additions & 0 deletions src/lang/eval.h
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,9 @@ ray_t* ray_let_fn(ray_t* name_obj, ray_t* val_expr);
ray_t* ray_cond_fn(ray_t** args, int64_t n);
ray_t* ray_do_fn(ray_t** args, int64_t n);
ray_t* ray_while_fn(ray_t** args, int64_t n);
ray_t* ray_times_fn(ray_t** args, int64_t n);
ray_t* ray_times_norm_fn(ray_t* x);
ray_t* ray_times_dec_fn(ray_t* x);
ray_t* ray_fn(ray_t** args, int64_t n);
ray_t* ray_raise_fn(ray_t* val);
ray_t* ray_try_fn(ray_t* expr, ray_t* handler_expr);
Expand Down
1 change: 1 addition & 0 deletions src/lang/internal.h
Original file line number Diff line number Diff line change
Expand Up @@ -526,6 +526,7 @@ ray_t* ray_binr_fn(ray_t* sorted, ray_t* val);
ray_t* ray_map_left_fn(ray_t** args, int64_t n);
ray_t* ray_map_right_fn(ray_t** args, int64_t n);
ray_t* ray_fold_left_fn(ray_t** args, int64_t n);
ray_t* ray_fold_while_fn(ray_t** args, int64_t n);
ray_t* ray_fold_right_fn(ray_t** args, int64_t n);
ray_t* ray_scan_left_fn(ray_t** args, int64_t n);
ray_t* ray_scan_right_fn(ray_t** args, int64_t n);
Expand Down
48 changes: 48 additions & 0 deletions src/ops/collection.c
Original file line number Diff line number Diff line change
Expand Up @@ -4366,6 +4366,54 @@ ray_t* ray_fold_left_fn(ray_t** args, int64_t n) {
return ray_fold_fn(args, n);
}

/* (fold-while pred f init coll) — a fold that stops when the running
* result says stop.
*
* Before each step the accumulator is offered to `pred`; a falsy answer
* ends the fold and yields the accumulator as it stands. The test comes
* BEFORE the first element, so a predicate that is false at the start
* returns `init` untouched and touches nothing.
*
* Deliberately unlike ray_fold_fn in one respect: that routes its
* collection through unbox_vec_arg -> to_boxed_list, boxing every element
* up front. For a primitive whose whole purpose is to stop early, paying
* for the tail it never reaches is exactly the cost being removed here
* (issue 588), so elements are pulled one at a time via collection_elem —
* the same way map_iterate walks its input. Stopping at element three of
* a million costs three boxed atoms, not a million. */
ray_t* ray_fold_while_fn(ray_t** args, int64_t n) {
if (n != 4) return ray_error("domain", "fold-while: requires exactly 4 args (pred, fn, init, coll), got %lld", (long long)n);
for (int64_t i = 0; i < n; i++)
if (ray_is_lazy(args[i])) args[i] = ray_lazy_materialize(args[i]);

ray_t* pred = args[0];
ray_t* fn = args[1];
ray_t* coll = args[3];
if (!is_collection(coll))
return ray_error("type", "fold-while: coll arg must be a collection, got %s", ray_type_name(coll->type));

ray_retain(args[2]);
ray_t* acc = args[2];
int64_t len = ray_len(coll);
for (int64_t i = 0; i < len; i++) {
ray_t* keep = call_fn1(pred, acc);
if (ray_is_lazy(keep)) keep = ray_lazy_materialize(keep);
if (RAY_IS_ERR(keep)) { ray_release(acc); return keep; }
int go = is_truthy(keep);
ray_release(keep);
if (!go) return acc;

int alloc = 0;
ray_t* elem = collection_elem(coll, i, &alloc);
ray_t* next = call_fn2(fn, acc, elem);
if (alloc) ray_release(elem);
ray_release(acc);
if (RAY_IS_ERR(next)) return next;
acc = next;
}
return acc;
}

/* (fold-right fn init coll) — right fold */
ray_t* ray_fold_right_fn(ray_t** args, int64_t n) {
if (n < 2) return ray_error("domain", "fold-right: requires at least 2 args (fn and vec), got %lld", (long long)n);
Expand Down
Loading
Loading