Skip to main content

plg_runtime/
control.rs

1//! Query-level control constructs and deterministic builtins.
2//!
3//! Clause bodies compile control flow to native code; this module only
4//! serves goals built at RUNTIME — the `--query` string today, call/1
5//! metacalls in M4. It walks goal TERMS, never clauses.
6//!
7//! The implementations mirror the compiled lowering exactly (same
8//! choice-point shapes, same commit heights), so a goal behaves
9//! identically whether it appears in a clause body or a query.
10
11use crate::builtins::pred;
12use crate::cell::*;
13use crate::machine::{ContFn, Machine, NO_SITE};
14use crate::solve::call_goal;
15use crate::unify::unify;
16
17/// Invoke the current continuation (a goal succeeded deterministically).
18fn invoke_k(m: &mut Machine) -> i32 {
19    let k = m.k_fn;
20    let e = m.k_env;
21    unsafe { k(m as *mut Machine, e) }
22}
23
24fn det(m: &mut Machine, ok: bool) -> i32 {
25    if ok { invoke_k(m) } else { 0 }
26}
27
28/// Try to handle `name/arity` as a control construct or deterministic
29/// builtin. Returns None when it's an ordinary predicate call.
30pub fn try_builtin(m: &mut Machine, name: &str, args_idx: usize, arity: u32) -> Option<i32> {
31    let mp = m as *mut Machine;
32    // Snapshot the argument words: goal args are read-only here and the
33    // heap may grow (frames) before they're consumed.
34    let a: Vec<Word> = (0..arity as usize).map(|i| m.heap[args_idx + i]).collect();
35    let arg = |i: usize| -> Word { a[i] };
36    let r = match (name, arity) {
37        (",", 2) => conjunction(m, arg(0), arg(1)),
38        (";", 2) => {
39            // `(C -> T ; E)` is if-then-else.
40            let lhs = m.deref(arg(0));
41            if tag_of(lhs) == TAG_STR {
42                let idx = payload(lhs) as usize;
43                let (f, n) = unpack_functor(m.heap[idx]);
44                if n == 2 && m.atoms.resolve(f) == "->" {
45                    let (c, t) = (m.heap[idx + 1], m.heap[idx + 2]);
46                    return Some(if_then_else(m, c, t, Some(arg(1))));
47                }
48            }
49            disjunction(m, arg(0), arg(1))
50        }
51        ("->", 2) => if_then_else(m, arg(0), arg(1), None),
52        ("\\+", 1) => naf(m, arg(0)),
53        ("once", 1) => once(m, arg(0)),
54        ("catch", 3) => catch_impl(m, arg(0), arg(1), arg(2)),
55        ("throw", 1) => {
56            crate::errors::throw_term(m, arg(0));
57            0
58        }
59        ("findall", 3) => findall_impl(m, arg(0), arg(1), arg(2)),
60        ("call", n) if n >= 1 => metacall_extend(m, arg(0), &a[1..]),
61        ("between", 3) => between_impl(m, arg(0), arg(1), arg(2)),
62        ("atom_concat", 3) => atom_concat_impl(m, arg(0), arg(1), arg(2), NO_SITE),
63        ("=", 2) => {
64            let ok = unify(m, arg(0), arg(1));
65            det(m, ok)
66        }
67        ("\\=", 2) => {
68            let ok = pred::plg_rt_b_neq(mp, arg(0), arg(1)) != 0;
69            det(m, ok)
70        }
71        ("is", 2) => {
72            // Runtime-walked (query/metacall): no compiled call site.
73            let ok = pred::plg_rt_b_is(mp, arg(0), arg(1), crate::machine::NO_SITE) != 0;
74            det(m, ok)
75        }
76        ("compare", 3) => {
77            let ok = pred::plg_rt_b_compare(mp, arg(0), arg(1), arg(2)) != 0;
78            det(m, ok)
79        }
80        (op, 2) if arith_op(op).is_some() => {
81            let ok = pred::plg_rt_b_arith_cmp(
82                mp,
83                arith_op(op).unwrap(),
84                arg(0),
85                arg(1),
86                crate::machine::NO_SITE,
87            ) != 0;
88            det(m, ok)
89        }
90        (op, 2) if order_op(op).is_some() => {
91            let ok = pred::plg_rt_b_term_cmp(mp, order_op(op).unwrap(), arg(0), arg(1)) != 0;
92            det(m, ok)
93        }
94        _ => {
95            let ok = det_builtin(mp, name, arity, &a)?;
96            det(m, ok)
97        }
98    };
99    Some(r)
100}
101
102/// Query-side dispatch for the deterministic builtin vocabulary —
103/// mirrors codegen's DET_BUILTINS table (lower.rs); the diff corpus
104/// guards the pair. Returns None for non-builtins.
105fn det_builtin(mp: *mut Machine, name: &str, arity: u32, a: &[Word]) -> Option<bool> {
106    use crate::builtins::{atomops, miscops, sortops, termops, typecheck};
107    let r = match (name, arity) {
108        ("var", 1) => typecheck::plg_rt_b_var_1(mp, a[0]),
109        ("nonvar", 1) => typecheck::plg_rt_b_nonvar_1(mp, a[0]),
110        ("atom", 1) => typecheck::plg_rt_b_atom_1(mp, a[0]),
111        ("number", 1) => typecheck::plg_rt_b_number_1(mp, a[0]),
112        ("integer", 1) => typecheck::plg_rt_b_integer_1(mp, a[0]),
113        ("float", 1) => typecheck::plg_rt_b_float_1(mp, a[0]),
114        ("compound", 1) => typecheck::plg_rt_b_compound_1(mp, a[0]),
115        ("is_list", 1) => typecheck::plg_rt_b_is_list_1(mp, a[0]),
116        // Runtime-walked (query/metacall): raising builtins get NO_SITE.
117        ("functor", 3) => termops::plg_rt_b_functor_3(mp, a[0], a[1], a[2], NO_SITE),
118        ("arg", 3) => termops::plg_rt_b_arg_3(mp, a[0], a[1], a[2], NO_SITE),
119        ("=..", 2) => termops::plg_rt_b_univ_2(mp, a[0], a[1], NO_SITE),
120        ("copy_term", 2) => termops::plg_rt_b_copy_term_2(mp, a[0], a[1]),
121        ("atom_length", 2) => atomops::plg_rt_b_atom_length_2(mp, a[0], a[1], NO_SITE),
122        ("atom_chars", 2) => atomops::plg_rt_b_atom_chars_2(mp, a[0], a[1], NO_SITE),
123        ("number_chars", 2) => atomops::plg_rt_b_number_chars_2(mp, a[0], a[1], NO_SITE),
124        ("number_codes", 2) => atomops::plg_rt_b_number_codes_2(mp, a[0], a[1], NO_SITE),
125        ("msort", 2) => sortops::plg_rt_b_msort_2(mp, a[0], a[1], NO_SITE),
126        ("sort", 2) => sortops::plg_rt_b_sort_2(mp, a[0], a[1], NO_SITE),
127        ("succ", 2) => miscops::plg_rt_b_succ_2(mp, a[0], a[1], NO_SITE),
128        ("plus", 3) => miscops::plg_rt_b_plus_3(mp, a[0], a[1], a[2], NO_SITE),
129        ("unify_with_occurs_check", 2) => {
130            miscops::plg_rt_b_unify_with_occurs_check_2(mp, a[0], a[1])
131        }
132        ("write", 1) => miscops::plg_rt_b_write_1(mp, a[0]),
133        ("writeq", 1) => miscops::plg_rt_b_writeq_1(mp, a[0]),
134        ("writeln", 1) => miscops::plg_rt_b_writeln_1(mp, a[0]),
135        _ => return None,
136    };
137    Some(r != 0)
138}
139
140/// Atom-only goals (`true`, `fail`, `!`).
141pub fn try_atom_builtin(m: &mut Machine, name: &str) -> Option<i32> {
142    match name {
143        "true" => Some(invoke_k(m)),
144        "nl" => {
145            let ok = crate::builtins::miscops::plg_rt_b_nl_0(m as *mut Machine) != 0;
146            Some(det(m, ok))
147        }
148        "fail" | "false" => Some(0),
149        "!" => {
150            // Cut to the walker barrier: 0 at the query top level,
151            // local inside call-like constructs (\+, once, ->-condition,
152            // call/N, findall goals). cut_to stops at catch frames.
153            let h = m.qbarrier;
154            m.cut_to(h);
155            Some(invoke_k(m))
156        }
157        _ => None,
158    }
159}
160
161/// ABI op codes — must match codegen's lower.rs tables.
162fn arith_op(name: &str) -> Option<i32> {
163    Some(match name {
164        "<" => 0,
165        ">" => 1,
166        "=<" => 2,
167        ">=" => 3,
168        "=:=" => 4,
169        "=\\=" => 5,
170        _ => return None,
171    })
172}
173
174fn order_op(name: &str) -> Option<i32> {
175    Some(match name {
176        "==" => 0,
177        "\\==" => 1,
178        "@<" => 2,
179        "@>" => 3,
180        "@=<" => 4,
181        "@>=" => 5,
182        _ => return None,
183    })
184}
185
186/// Snapshot the continuation AND the walker cut barrier (3 cells:
187/// k_fn, k_env, qbarrier) — the runtime mirror of compiled cut slots.
188fn save_k(m: &mut Machine, frame: usize, at: usize) {
189    m.heap[frame + at] = m.k_fn as usize as u64;
190    m.heap[frame + at + 1] = m.k_env;
191    m.heap[frame + at + 2] = m.qbarrier as u64;
192}
193
194/// Restore continuation + barrier from a frame.
195fn load_k(m: &mut Machine, frame: usize, at: usize) -> (ContFn, u64) {
196    let k: ContFn = unsafe { std::mem::transmute(m.heap[frame + at] as usize) };
197    m.qbarrier = m.heap[frame + at + 2] as usize;
198    (k, m.heap[frame + at + 1])
199}
200
201/// `,`/2: run A with a continuation that runs B.
202fn conjunction(m: &mut Machine, a: Word, b: Word) -> i32 {
203    let frame = m.frame_alloc(4);
204    m.heap[frame] = b;
205    save_k(m, frame, 1);
206    m.k_fn = conj_k;
207    m.k_env = frame as u64;
208    call_goal(m, a)
209}
210
211unsafe extern "C" fn conj_k(m: *mut Machine, env: u64) -> i32 {
212    let m = unsafe { &mut *m };
213    let frame = env as usize;
214    let b = m.heap[frame];
215    let (kf, ke) = load_k(m, frame, 1);
216    m.k_fn = kf;
217    m.k_env = ke;
218    call_goal(m, b)
219}
220
221/// `(A ; B)`: push a CP retrying B (with the current k restored), run A.
222fn disjunction(m: &mut Machine, a: Word, b: Word) -> i32 {
223    let frame = m.frame_alloc(4);
224    m.heap[frame] = b;
225    save_k(m, frame, 1);
226    m.push_cp(disj_retry, frame as u64);
227    call_goal(m, a)
228}
229
230unsafe extern "C" fn disj_retry(m: *mut Machine, env: u64) -> i32 {
231    let m = unsafe { &mut *m };
232    let frame = env as usize;
233    let b = m.heap[frame];
234    let (kf, ke) = load_k(m, frame, 1);
235    m.k_fn = kf;
236    m.k_env = ke;
237    call_goal(m, b)
238}
239
240/// `(C -> T ; E)` / `(C -> T)`: commit to C's first solution.
241fn if_then_else(m: &mut Machine, c: Word, t: Word, e: Option<Word>) -> i32 {
242    let h = m.cps.len() as u64; // BEFORE the else CP
243    if let Some(e) = e {
244        let ef = m.frame_alloc(4);
245        m.heap[ef] = e;
246        save_k(m, ef, 1);
247        m.push_cp(disj_retry, ef as u64);
248    }
249    let tf = m.frame_alloc(5);
250    m.heap[tf] = t;
251    save_k(m, tf, 1);
252    m.heap[tf + 4] = h;
253    m.k_fn = ite_then;
254    m.k_env = tf as u64;
255    // The condition is call-like: `!` inside it is local (cuts to the
256    // height AFTER the else CP).
257    m.qbarrier = m.cps.len();
258    call_goal(m, c)
259}
260
261unsafe extern "C" fn ite_then(m: *mut Machine, env: u64) -> i32 {
262    let m = unsafe { &mut *m };
263    let frame = env as usize;
264    let h = m.heap[frame + 4] as usize;
265    m.cps.truncate(h); // commit: kill E and C's alternatives
266    let t = m.heap[frame];
267    let (kf, ke) = load_k(m, frame, 1); // also restores the outer barrier
268    m.k_fn = kf;
269    m.k_env = ke;
270    call_goal(m, t)
271}
272
273/// `once(G)`: commit to G's first solution, then continue.
274fn once(m: &mut Machine, g: Word) -> i32 {
275    let h = m.cps.len() as u64;
276    let frame = m.frame_alloc(4);
277    save_k(m, frame, 0);
278    m.heap[frame + 3] = h;
279    m.k_fn = once_then;
280    m.k_env = frame as u64;
281    m.qbarrier = m.cps.len(); // call-like: `!` inside G is local
282    call_goal(m, g)
283}
284
285unsafe extern "C" fn once_then(m: *mut Machine, env: u64) -> i32 {
286    let m = unsafe { &mut *m };
287    let frame = env as usize;
288    let h = m.heap[frame + 3] as usize;
289    m.cps.truncate(h);
290    let (kf, ke) = load_k(m, frame, 0);
291    m.k_fn = kf;
292    m.k_env = ke;
293    invoke_k(m)
294}
295
296/// `\+ G`: push a CP that CONTINUES (driver rewind undoes G's
297/// bindings); if G succeeds, cut to the pre-NAF height and fail.
298fn naf(m: &mut Machine, g: Word) -> i32 {
299    let h = m.cps.len() as u64;
300    let cf = m.frame_alloc(3);
301    save_k(m, cf, 0);
302    m.push_cp(naf_continue, cf as u64);
303    let ff = m.frame_alloc(1);
304    m.heap[ff] = h;
305    m.k_fn = naf_found;
306    m.k_env = ff as u64;
307    // Call-like: `!` inside G is local (cannot prune the continue CP).
308    m.qbarrier = m.cps.len();
309    call_goal(m, g)
310}
311
312unsafe extern "C" fn naf_continue(m: *mut Machine, env: u64) -> i32 {
313    let m = unsafe { &mut *m };
314    let frame = env as usize;
315    let (kf, ke) = load_k(m, frame, 0);
316    m.k_fn = kf;
317    m.k_env = ke;
318    invoke_k(m)
319}
320
321unsafe extern "C" fn naf_found(m: *mut Machine, env: u64) -> i32 {
322    let m = unsafe { &mut *m };
323    let h = m.heap[env as usize] as usize;
324    m.cps.truncate(h); // removes the continue-CP and G's alternatives
325    0
326}
327
328/// `catch(Goal, Catcher, Recovery)`: push a catch frame (transparent to
329/// normal backtracking, a target for error unwinding in solve::drive
330/// and a barrier for cut), then run Goal with the current continuation.
331fn catch_impl(m: &mut Machine, goal: Word, catcher: Word, recovery: Word) -> i32 {
332    let frame = m.frame_alloc(5);
333    m.heap[frame] = catcher;
334    m.heap[frame + 1] = recovery;
335    save_k(m, frame, 2);
336    m.push_catch_cp(catch_retry, frame as u64);
337    call_goal(m, goal)
338}
339
340/// Backtracking INTO a catch frame (no error in flight) is transparent:
341/// the frame offers no alternatives.
342unsafe extern "C" fn catch_retry(_m: *mut Machine, _env: u64) -> i32 {
343    0
344}
345
346/// `findall(Template, Goal, Bag)`: run Goal to exhaustion in a bounded
347/// sub-search, snapshotting Template per solution; rewind everything;
348/// unify Bag with the collected list. Errors propagate out (a catch
349/// inside Goal is handled by the shared driver within our floor).
350fn findall_impl(m: &mut Machine, template: Word, goal: Word, bag: Word) -> i32 {
351    let floor = m.cps.len();
352    let tmark = m.trail.len();
353    let hmark = m.heap.len();
354    let saved_k = (m.k_fn, m.k_env, m.qbarrier);
355    m.findall_stack.push(Vec::new());
356    let cf = m.frame_alloc(1);
357    m.heap[cf] = template;
358    m.k_fn = findall_collect;
359    m.k_env = cf as u64;
360    m.qbarrier = m.cps.len(); // call-like: `!` inside Goal is local
361    let r = call_goal(m, goal);
362    crate::solve::drive(m, floor, r); // collector always returns 0
363    let results = m.findall_stack.pop().unwrap();
364    m.k_fn = saved_k.0;
365    m.k_env = saved_k.1;
366    m.qbarrier = saved_k.2;
367    if m.error.is_some() {
368        return 0;
369    }
370    // Discard the goal's bindings and workspace, then build the bag.
371    m.rewind_to(tmark, hmark);
372    let mut w = make_atom(plg_shared::atom::ATOM_NIL);
373    for buf in results.iter().rev() {
374        let e = crate::copyterm::restore_from_buf(m, buf);
375        let idx = m.heap.len();
376        m.heap.push(e);
377        m.heap.push(w);
378        w = make(TAG_LST, idx as u64);
379    }
380    let ok = unify(m, bag, w);
381    det(m, ok)
382}
383
384unsafe extern "C" fn findall_collect(m: *mut Machine, env: u64) -> i32 {
385    let m = unsafe { &mut *m };
386    let template = m.heap[env as usize];
387    let buf = crate::copyterm::copy_to_buf(m, template);
388    m.findall_stack
389        .last_mut()
390        .expect("collector outside findall")
391        .push(buf);
392    0 // force backtracking into the next solution
393}
394
395/// `call/N`: extend the goal with extra arguments (ISO 7.8.3 partial
396/// application), then dispatch it.
397fn metacall_extend(m: &mut Machine, goal: Word, extras: &[Word]) -> i32 {
398    let goal = m.deref(goal);
399    // call/N is opaque to cut (ISO): `!` inside the called goal is local.
400    m.qbarrier = m.cps.len();
401    if extras.is_empty() {
402        return call_goal(m, goal);
403    }
404    let extended = match tag_of(goal) {
405        TAG_ATOM => {
406            let f = atom_id(goal);
407            let idx = m.heap.len();
408            m.heap.push(pack_functor(f, extras.len() as u32));
409            m.heap.extend_from_slice(extras);
410            make(TAG_STR, idx as u64)
411        }
412        TAG_STR => {
413            let sidx = payload(goal) as usize;
414            let (f, n) = unpack_functor(m.heap[sidx]);
415            let idx = m.heap.len();
416            m.heap.push(pack_functor(f, n + extras.len() as u32));
417            for i in 0..n as usize {
418                let w = m.heap[sidx + 1 + i];
419                m.heap.push(w);
420            }
421            m.heap.extend_from_slice(extras);
422            make(TAG_STR, idx as u64)
423        }
424        TAG_REF => {
425            crate::errors::instantiation(m, "call/N requires a bound goal");
426            return 0;
427        }
428        _ => {
429            crate::errors::type_error(m, "callable", goal, "Goal is not callable");
430            return 0;
431        }
432    };
433    call_goal(m, extended)
434}
435
436/// `between(Low, High, X)` — the one nondeterministic builtin: with X
437/// unbound it enumerates Low..=High via a runtime-retried choice point.
438fn between_impl(m: &mut Machine, lo_w: Word, hi_w: Word, x_w: Word) -> i32 {
439    let Some(lo) = int_arg(m, lo_w, "between/3") else {
440        return 0;
441    };
442    let Some(hi) = int_arg(m, hi_w, "between/3") else {
443        return 0;
444    };
445    let x = m.deref(x_w);
446    match tag_of(x) {
447        TAG_INT | TAG_BIG => {
448            let xv = if tag_of(x) == TAG_INT {
449                int_value(x)
450            } else {
451                m.heap[payload(x) as usize] as i64
452            };
453            det(m, lo <= xv && xv <= hi)
454        }
455        TAG_REF => {
456            if lo > hi {
457                return 0;
458            }
459            // Frame: [x, current, high, k_fn, k_env, qbarrier].
460            // `current` is mutated in place across retries (untrailed
461            // by design — the frame is control state, not a term).
462            let frame = m.frame_alloc(6);
463            m.heap[frame] = x;
464            m.heap[frame + 1] = lo as u64;
465            m.heap[frame + 2] = hi as u64;
466            save_k(m, frame, 3);
467            if lo < hi {
468                m.push_cp(between_retry, frame as u64);
469            }
470            let w = int_to_word(m, lo);
471            let ok = unify(m, x, w);
472            debug_assert!(ok, "binding a fresh var cannot fail");
473            invoke_k(m)
474        }
475        _ => {
476            crate::errors::type_error(m, "integer", x, "between/3 requires an integer");
477            0
478        }
479    }
480}
481
482unsafe extern "C" fn between_retry(m: *mut Machine, env: u64) -> i32 {
483    let m = unsafe { &mut *m };
484    let frame = env as usize;
485    let x = m.heap[frame];
486    let cur = m.heap[frame + 1] as i64 + 1;
487    let hi = m.heap[frame + 2] as i64;
488    m.heap[frame + 1] = cur as u64;
489    if cur < hi {
490        m.push_cp(between_retry, frame as u64);
491    }
492    let (kf, ke) = load_k(m, frame, 3);
493    m.k_fn = kf;
494    m.k_env = ke;
495    let w = int_to_word(m, cur);
496    let ok = unify(m, x, w);
497    debug_assert!(ok, "x rewound to unbound before retry");
498    invoke_k(m)
499}
500
501/// Build an integer word: immediate when it fits i61, boxed otherwise.
502fn int_to_word(m: &mut Machine, n: i64) -> Word {
503    if (INT_MIN..=INT_MAX).contains(&n) {
504        make_int(n)
505    } else {
506        let idx = m.heap.len();
507        m.heap.push(n as u64);
508        make(TAG_BIG, idx as u64)
509    }
510}
511
512/// Deref an integer argument for between/3 (bounds must be bound
513/// integers).
514fn int_arg(m: &mut Machine, w: Word, who: &str) -> Option<i64> {
515    let w = m.deref(w);
516    match tag_of(w) {
517        TAG_INT => Some(int_value(w)),
518        TAG_BIG => Some(m.heap[payload(w) as usize] as i64),
519        TAG_REF => {
520            let ctx = format!("{who} requires bound integer bounds");
521            crate::errors::instantiation(m, &ctx);
522            None
523        }
524        _ => {
525            let ctx = format!("{who} requires an integer");
526            crate::errors::type_error(m, "integer", w, &ctx);
527            None
528        }
529    }
530}
531
532/// Compiled-code entry for between/3 (uniform predicate signature,
533/// arguments in the A registers — dispatched like a user predicate).
534/// # Safety
535/// Called from generated code with the live Machine pointer.
536#[unsafe(no_mangle)]
537pub unsafe extern "C" fn plg_rt_pred_between_3(m: *mut Machine, _env: u64) -> i32 {
538    let m = unsafe { &mut *m };
539    let (lo, hi, x) = (m.areg[0], m.areg[1], m.areg[2]);
540    between_impl(m, lo, hi, x)
541}
542
543/// The atom name of `w` if it is an atom, else `None` (owned so the caller can
544/// then borrow `m` mutably to intern results).
545fn atom_str_opt(m: &Machine, w: Word) -> Option<String> {
546    (tag_of(w) == TAG_ATOM).then(|| m.atoms.resolve(atom_id(w)).to_string())
547}
548
549/// Bind `a_w`/`b_w` to the prefix/suffix of `sc` split before its `i`-th
550/// character (split on char boundaries so multi-byte atoms are safe). Returns
551/// whether both unified — `false` only when the two args alias one variable
552/// and this split's halves differ (e.g. `atom_concat(X, X, abcd)` at a split
553/// where prefix ≠ suffix), which is a normal per-alternative miss, not an error.
554fn bind_split(m: &mut Machine, a_w: Word, b_w: Word, sc: &str, i: usize) -> bool {
555    let byte = sc.char_indices().nth(i).map_or(sc.len(), |(b, _)| b);
556    let (pre, suf) = sc.split_at(byte);
557    let pid = m.atoms.intern(pre);
558    let sid = m.atoms.intern(suf);
559    unify(m, a_w, make_atom(pid)) && unify(m, b_w, make_atom(sid))
560}
561
562/// `atom_concat(A, B, C)` — ISO 8.16.2, all modes (issue #35). Forward when A
563/// and B are atoms (concatenate, unify with C); otherwise C must be a bound
564/// atom and this is the relational splitter: a known prefix (A bound) or suffix
565/// (B bound) selects the one matching decomposition, and A and B both unbound
566/// enumerate every decomposition on backtracking — the same multi-mode shape
567/// `append/3` has for lists. `site` carries the compiled call site for error
568/// provenance (NO_SITE on the query/metacall path).
569fn atom_concat_impl(m: &mut Machine, a_w: Word, b_w: Word, c_w: Word, site: u32) -> i32 {
570    m.error_site = site;
571    let wa = m.deref(a_w);
572    let wb = m.deref(b_w);
573    let a_atom = atom_str_opt(m, wa);
574    let b_atom = atom_str_opt(m, wb);
575    // A bound non-atom (number, compound, …) is a type_error — ISO admits only
576    // atoms here. An unbound var is fine (it's a split-mode output).
577    if a_atom.is_none() && tag_of(wa) != TAG_REF {
578        crate::errors::type_error(m, "atom", wa, "atom_concat/3: arguments must be atoms");
579        return 0;
580    }
581    if b_atom.is_none() && tag_of(wb) != TAG_REF {
582        crate::errors::type_error(m, "atom", wb, "atom_concat/3: arguments must be atoms");
583        return 0;
584    }
585    // Forward: both bound atoms → concatenate and unify with C.
586    if let (Some(sa), Some(sb)) = (&a_atom, &b_atom) {
587        let id = m.atoms.intern(&format!("{sa}{sb}"));
588        m.error_site = NO_SITE;
589        let ok = unify(m, c_w, make_atom(id));
590        return det(m, ok);
591    }
592    // Split modes need C to be a bound atom.
593    let wc = m.deref(c_w);
594    let Some(sc) = atom_str_opt(m, wc) else {
595        if tag_of(wc) == TAG_REF {
596            // (A or B unbound) and C unbound → not enough to proceed.
597            crate::errors::instantiation(
598                m,
599                "atom_concat/3: arguments not sufficiently instantiated",
600            );
601        } else {
602            crate::errors::type_error(m, "atom", wc, "atom_concat/3: arguments must be atoms");
603        }
604        return 0;
605    };
606    m.error_site = NO_SITE; // no further raises past this point
607    // Known prefix: A bound, B unbound → unique split iff C starts with A.
608    if let Some(sa) = &a_atom {
609        return match sc.strip_prefix(sa.as_str()) {
610            Some(rest) => {
611                let id = m.atoms.intern(rest);
612                let ok = unify(m, b_w, make_atom(id));
613                det(m, ok)
614            }
615            None => 0,
616        };
617    }
618    // Known suffix: B bound, A unbound → unique split iff C ends with B.
619    if let Some(sb) = &b_atom {
620        return match sc.strip_suffix(sb.as_str()) {
621            Some(pre) => {
622                let id = m.atoms.intern(pre);
623                let ok = unify(m, a_w, make_atom(id));
624                det(m, ok)
625            }
626            None => 0,
627        };
628    }
629    // A and B both unbound → enumerate every decomposition of C.
630    // Frame: [a_w, b_w, c_atom_id, cur_index, nchars, k_fn, k_env, qbarrier].
631    let nchars = sc.chars().count();
632    let frame = m.frame_alloc(8);
633    m.heap[frame] = a_w;
634    m.heap[frame + 1] = b_w;
635    m.heap[frame + 2] = atom_id(wc) as u64;
636    m.heap[frame + 3] = 0;
637    m.heap[frame + 4] = nchars as u64;
638    save_k(m, frame, 5);
639    if nchars >= 1 {
640        m.push_cp(atom_concat_retry, frame as u64);
641    }
642    if bind_split(m, a_w, b_w, &sc, 0) {
643        invoke_k(m)
644    } else {
645        0 // alias miss at split 0 → engine backtracks into the retry
646    }
647}
648
649unsafe extern "C" fn atom_concat_retry(m: *mut Machine, env: u64) -> i32 {
650    let m = unsafe { &mut *m };
651    let frame = env as usize;
652    let cur = m.heap[frame + 3] as usize + 1;
653    let nchars = m.heap[frame + 4] as usize;
654    m.heap[frame + 3] = cur as u64;
655    if cur < nchars {
656        m.push_cp(atom_concat_retry, frame as u64);
657    }
658    let (kf, ke) = load_k(m, frame, 5);
659    m.k_fn = kf;
660    m.k_env = ke;
661    let (a_w, b_w) = (m.heap[frame], m.heap[frame + 1]);
662    let sc = m.atoms.resolve(m.heap[frame + 2] as u32).to_string();
663    if bind_split(m, a_w, b_w, &sc, cur) {
664        invoke_k(m)
665    } else {
666        0
667    }
668}
669
670/// Compiled-code entry for atom_concat/3 (nondeterministic in split mode, so —
671/// like between/3 — dispatched as a predicate with args in the A registers).
672/// `env` carries the call-site id for error provenance (SPANS.md Layer 3).
673/// # Safety
674/// Called from generated code with the live Machine pointer.
675#[unsafe(no_mangle)]
676pub unsafe extern "C" fn plg_rt_pred_atom_concat_3(m: *mut Machine, env: u64) -> i32 {
677    let m = unsafe { &mut *m };
678    let (a, b, c) = (m.areg[0], m.areg[1], m.areg[2]);
679    atom_concat_impl(m, a, b, c, env as u32)
680}
681
682/// Compiled-code entries for control builtins taking goal terms.
683/// # Safety
684/// Called from generated code with the live Machine pointer.
685#[unsafe(no_mangle)]
686pub unsafe extern "C" fn plg_rt_metacall(m: *mut Machine, goal: u64) -> i32 {
687    let m = unsafe { &mut *m };
688    // Goals reaching the walker from compiled code are call-like:
689    // a runtime-walked `!` inside them is local.
690    m.qbarrier = m.cps.len();
691    call_goal(m, goal)
692}
693
694/// Fast-path resolver for the metacall trampoline (#23): if `goal` is a
695/// simple compiled-predicate call (after peeling a single `call/1` wrapper),
696/// marshal its arguments and return the entry function pointer as an integer
697/// for generated IR to `musttail` into — giving `call(pred(...))` tail
698/// recursion constant C stack, like a direct call. Returns 0 for anything the
699/// full walker must handle (builtins, control constructs, `call/N` with extra
700/// args, variables, undefined predicates); the caller then falls back to
701/// `plg_rt_metacall`, which is bounded by the depth guard in `call_goal`.
702///
703/// Sets `qbarrier` exactly as `plg_rt_metacall` does, so cut-transparency of
704/// `call/N` is identical on both paths. The fast path does NOT bump
705/// `metacall_depth`: a `musttail` into the resolved entry leaves no walker
706/// frame to bound (only the slow path re-enters `call_goal`, which guards).
707///
708/// # Safety
709/// Called from generated code with the live Machine pointer.
710#[unsafe(no_mangle)]
711pub unsafe extern "C" fn plg_rt_metacall_resolve(m: *mut Machine, goal: u64) -> u64 {
712    let m = unsafe { &mut *m };
713    m.qbarrier = m.cps.len();
714    match resolve_goal_ptr(m, goal) {
715        Some(f) => f as usize as u64,
716        None => 0,
717    }
718}
719
720fn resolve_goal_ptr(m: &mut Machine, mut goal: Word) -> Option<ContFn> {
721    loop {
722        goal = m.deref(goal);
723        match tag_of(goal) {
724            TAG_ATOM => return crate::solve::resolve_simple(m, atom_id(goal), 0, 0),
725            TAG_STR => {
726                let idx = payload(goal) as usize;
727                let (f, n) = unpack_functor(m.heap[idx]);
728                // Peel `call/1` wrappers *iteratively* so `call(pred(..))`
729                // trampolines — and so even a pathological `call(call(...))`
730                // chain can't overflow the resolver's own stack. `call/N` with
731                // extras (and other shapes) go to the walker, which builds the
732                // extended goal in `metacall_extend`.
733                if n == 1 && m.atoms.resolve(f) == "call" {
734                    goal = m.heap[idx + 1];
735                    continue;
736                }
737                return crate::solve::resolve_simple(m, f, n, idx + 1);
738            }
739            _ => return None,
740        }
741    }
742}
743
744/// # Safety
745/// Called from generated code with the live Machine pointer.
746#[unsafe(no_mangle)]
747pub unsafe extern "C" fn plg_rt_b_catch_3(m: *mut Machine, g: u64, c: u64, r: u64) -> i32 {
748    catch_impl(unsafe { &mut *m }, g, c, r)
749}
750
751/// # Safety
752/// Called from generated code with the live Machine pointer.
753#[unsafe(no_mangle)]
754pub unsafe extern "C" fn plg_rt_b_throw_1(m: *mut Machine, ball: u64) -> i32 {
755    crate::errors::throw_term(unsafe { &mut *m }, ball);
756    0
757}
758
759/// # Safety
760/// Called from generated code with the live Machine pointer.
761#[unsafe(no_mangle)]
762pub unsafe extern "C" fn plg_rt_b_findall_3(m: *mut Machine, t: u64, g: u64, b: u64) -> i32 {
763    findall_impl(unsafe { &mut *m }, t, g, b)
764}
765
766#[cfg(test)]
767mod tests {
768    use crate::machine::Machine;
769    use crate::query::parse_query;
770    use crate::solve::{Outcome, solve};
771    use plg_shared::StringInterner;
772
773    fn run(query: &str) -> (Vec<String>, Option<String>) {
774        let mut m = Machine::new(StringInterner::new(), Vec::new());
775        let goal = parse_query(&mut m, query).unwrap();
776        let outcome = solve(&mut m, goal);
777        let err = match outcome {
778            Outcome::Error => Some(m.error.take().unwrap().message),
779            Outcome::Done => None,
780        };
781        let sols = m
782            .solutions
783            .iter()
784            .map(|s| {
785                s.bindings
786                    .iter()
787                    .map(|b| format!("{}={}", b.name, b.text))
788                    .collect::<Vec<_>>()
789                    .join(",")
790            })
791            .collect();
792        (sols, err)
793    }
794
795    #[test]
796    fn top_level_is_and_comparison() {
797        assert_eq!(run("X is 2 + 3 * 4").0, vec!["X=14"]);
798        assert_eq!(run("1 < 2").0, vec![""]);
799        assert_eq!(run("2 < 1").0, Vec::<String>::new());
800    }
801
802    #[test]
803    fn top_level_disjunction_enumerates() {
804        assert_eq!(run("(X = 1 ; X = 2)").0, vec!["X=1", "X=2"]);
805    }
806
807    #[test]
808    fn top_level_ite_and_naf() {
809        assert_eq!(run("(1 < 2 -> X = yes ; X = no)").0, vec!["X=yes"]);
810        assert_eq!(run("(2 < 1 -> X = yes ; X = no)").0, vec!["X=no"]);
811        assert_eq!(run("\\+ 2 < 1").0, vec![""]);
812        assert_eq!(run("\\+ 1 < 2").0, Vec::<String>::new());
813        // NAF undoes inner bindings.
814        assert_eq!(run("\\+ (X = 1, 2 < 1), X = ok").0, vec!["X=ok"]);
815    }
816
817    #[test]
818    fn top_level_once_commits() {
819        assert_eq!(run("once((X = 1 ; X = 2))").0, vec!["X=1"]);
820    }
821
822    #[test]
823    fn errors_propagate() {
824        let (_, err) = run("X is 1 // 0");
825        assert!(err.unwrap().contains("zero_divisor"));
826    }
827}
828
829#[cfg(test)]
830mod m4_tests {
831    use crate::machine::Machine;
832    use crate::query::parse_query;
833    use crate::solve::{Outcome, solve};
834    use plg_shared::StringInterner;
835
836    fn run(query: &str) -> (Vec<String>, Option<String>) {
837        let mut m = Machine::new(StringInterner::new(), Vec::new());
838        let goal = parse_query(&mut m, query).unwrap();
839        let outcome = solve(&mut m, goal);
840        let err = match outcome {
841            Outcome::Error => Some(m.error.take().unwrap().message),
842            Outcome::Done => None,
843        };
844        let sols = m
845            .solutions
846            .iter()
847            .map(|s| {
848                s.bindings
849                    .iter()
850                    .map(|b| format!("{}={}", b.name, b.text))
851                    .collect::<Vec<_>>()
852                    .join(",")
853            })
854            .collect();
855        (sols, err)
856    }
857
858    #[test]
859    fn throw_uncaught_propagates_with_rendered_ball() {
860        let (sols, err) = run("throw(my_ball)");
861        assert!(sols.is_empty());
862        assert_eq!(err.unwrap(), "my_ball");
863    }
864
865    #[test]
866    fn catch_catches_matching_ball_and_runs_recovery() {
867        let (sols, err) = run("catch(throw(oops(1)), oops(N), X = caught(N))");
868        assert!(err.is_none());
869        assert_eq!(sols, vec!["N=1,X=caught(1)"]);
870    }
871
872    #[test]
873    fn catch_passes_nonmatching_ball_outward() {
874        let (sols, err) = run("catch(throw(other), oops(_), X = no)");
875        assert!(sols.is_empty());
876        assert_eq!(err.unwrap(), "other");
877    }
878
879    #[test]
880    fn nested_catch_inner_first() {
881        let (sols, err) = run("catch(catch(throw(b), a, X = inner_a), b, X = outer_b)");
882        assert!(err.is_none());
883        assert_eq!(sols, vec!["X=outer_b"]);
884    }
885
886    #[test]
887    fn catch_traps_builtin_errors() {
888        let (sols, err) = run("catch(X is 1 // 0, error(evaluation_error(E), _), Y = E)");
889        assert!(err.is_none());
890        assert_eq!(sols, vec!["E=zero_divisor,X=_0,Y=zero_divisor"]);
891    }
892
893    #[test]
894    fn catch_is_transparent_to_normal_backtracking() {
895        let (sols, err) = run("catch((X = 1 ; X = 2), _, fail)");
896        assert!(err.is_none());
897        assert_eq!(sols, vec!["X=1", "X=2"]);
898    }
899
900    #[test]
901    fn step_limit_is_not_catchable() {
902        let mut m = Machine::new(StringInterner::new(), Vec::new());
903        m.step_limit = 1;
904        m.steps = 1; // next step() trips the limit
905        assert!(!m.step());
906        let goal = parse_query(&mut m, "catch(true, _, true)").unwrap();
907        // error pre-armed and uncatchable: solve returns Error untouched
908        assert!(matches!(solve(&mut m, goal), Outcome::Error));
909        assert!(m.error.as_ref().unwrap().uncatchable);
910    }
911
912    #[test]
913    fn findall_collects_and_rewinds() {
914        let (sols, err) = run("findall(X, (X = 1 ; X = 2 ; X = 3), L)");
915        assert!(err.is_none());
916        assert_eq!(sols, vec!["L=[1, 2, 3],X=_0"]);
917    }
918
919    #[test]
920    fn findall_empty_on_failure() {
921        let (sols, err) = run("findall(X, fail, L)");
922        assert!(err.is_none());
923        assert_eq!(sols, vec!["L=[],X=_0"]);
924    }
925
926    #[test]
927    fn findall_propagates_errors() {
928        let (sols, err) = run("findall(X, throw(bad), L)");
929        assert!(sols.is_empty());
930        assert_eq!(err.unwrap(), "bad");
931    }
932
933    #[test]
934    fn nested_findall() {
935        let (sols, err) = run("findall(L1, (Y = 2, findall(X, (X = 1 ; X = Y), L1)), L)");
936        assert!(err.is_none());
937        assert_eq!(sols.len(), 1);
938        assert!(sols[0].contains("L=[[1, 2]]"), "{sols:?}");
939    }
940
941    #[test]
942    fn call_n_extends_goals() {
943        let (sols, err) = run("call(=, X, 7)");
944        assert!(err.is_none());
945        assert_eq!(sols, vec!["X=7"]);
946        let (sols, _) = run("G = (X = 1 ; X = 2), call(G)");
947        assert_eq!(sols.len(), 2);
948    }
949
950    #[test]
951    fn call_unbound_is_instantiation_error() {
952        let (sols, err) = run("call(X)");
953        assert!(sols.is_empty());
954        assert!(err.unwrap().contains("instantiation_error"));
955    }
956
957    #[test]
958    fn between_enumerates_and_checks() {
959        let (sols, err) = run("between(1, 4, X)");
960        assert!(err.is_none());
961        assert_eq!(sols, vec!["X=1", "X=2", "X=3", "X=4"]);
962        let (sols, _) = run("between(1, 4, 3)");
963        assert_eq!(sols.len(), 1);
964        let (sols, _) = run("between(1, 4, 9)");
965        assert!(sols.is_empty());
966        let (sols, _) = run("between(3, 1, X)");
967        assert!(sols.is_empty());
968        // single-value range: no choice point churn
969        let (sols, _) = run("between(2, 2, X)");
970        assert_eq!(sols, vec!["X=2"]);
971    }
972
973    #[test]
974    fn findall_with_between() {
975        let (sols, err) = run("findall(X, between(1, 5, X), L)");
976        assert!(err.is_none());
977        assert_eq!(sols, vec!["L=[1, 2, 3, 4, 5],X=_0"]);
978    }
979}
980
981#[cfg(test)]
982mod vocab_invariant {
983    //! Runtime half of the `plg-shared::builtins` invariant.
984    //! `det_builtin` above is the
985    //! query-side mirror of codegen's `DET_BUILTINS`; this asserts the
986    //! arms it handles are EXACTLY the arity>0 `Det` rows of `BUILTINS`
987    //! (`nl/0` is Det but dispatched via `try_atom_builtin`, so it is
988    //! excluded here). Referenced only under `#[cfg(test)]` — the `doc`
989    //! strings never reach a compiled program binary.
990    use plg_shared::{BUILTINS, builtins::BuiltinKind};
991    use std::collections::BTreeSet;
992
993    /// Hand mirror of the `det_builtin` match arms. Adding an arm there
994    /// without updating this (or `BUILTINS`) turns the test red.
995    #[rustfmt::skip]
996    const DET_DISPATCH: &[(&str, u32)] = &[
997        ("var", 1), ("nonvar", 1), ("atom", 1), ("number", 1), ("integer", 1),
998        ("float", 1), ("compound", 1), ("is_list", 1), ("functor", 3), ("arg", 3),
999        ("=..", 2), ("copy_term", 2), ("atom_length", 2),
1000        ("atom_chars", 2), ("number_chars", 2), ("number_codes", 2), ("msort", 2),
1001        ("sort", 2), ("succ", 2), ("plus", 3), ("unify_with_occurs_check", 2),
1002        ("write", 1), ("writeq", 1), ("writeln", 1),
1003    ];
1004
1005    #[test]
1006    fn det_dispatch_equals_shared_det_subset() {
1007        let dispatch: BTreeSet<(&str, u32)> = DET_DISPATCH.iter().copied().collect();
1008        let shared_det: BTreeSet<(&str, u32)> = BUILTINS
1009            .iter()
1010            .filter(|s| s.kind == BuiltinKind::Det && s.arity > 0)
1011            .map(|s| (s.name, s.arity))
1012            .collect();
1013        assert_eq!(
1014            dispatch, shared_det,
1015            "runtime det dispatch diverges from BUILTINS Det subset \
1016             (left = control.rs, right = shared table)"
1017        );
1018    }
1019}