Skip to main content

plg_runtime/
render.rs

1//! Solution rendering: the v1 wire contract, byte-compatible.
2//!
3//! JSON is hand-rolled (no serde in the runtime — binary size) but must
4//! match serde_json's output for the value shapes v1 produced: object
5//! keys in sorted order (serde_json's default BTreeMap), the same
6//! string escaping, and `{"functor":...,"args":[...]}` sorting to
7//! `{"args":...,"functor":...}` exactly as v1 emitted it.
8
9use crate::cell::*;
10use crate::machine::Machine;
11use plg_shared::atom::ATOM_NIL;
12
13/// One captured solution: bindings sorted by variable name (v1 rule),
14/// rendered immediately (terms are undone by backtracking afterwards).
15pub struct RenderedSolution {
16    /// (name, json_value, text_value) per query variable, `_` excluded.
17    pub bindings: Vec<(String, String, String)>,
18}
19
20/// Capture the current solution from the machine's query variables.
21pub fn capture_solution(m: &Machine) -> RenderedSolution {
22    let mut vars: Vec<_> = m.query_vars.iter().collect();
23    vars.sort_by(|a, b| a.0.cmp(&b.0));
24    let bindings = vars
25        .into_iter()
26        .filter(|(name, _)| name != "_")
27        .map(|(name, idx)| {
28            let w = m.deref(make_ref(*idx));
29            (name.clone(), term_to_json(m, w), term_to_string(m, w))
30        })
31        .collect();
32    RenderedSolution { bindings }
33}
34
35/// serde_json-compatible string escaping.
36pub fn json_escape(s: &str) -> String {
37    let mut out = String::with_capacity(s.len() + 2);
38    for c in s.chars() {
39        match c {
40            '"' => out.push_str("\\\""),
41            '\\' => out.push_str("\\\\"),
42            '\n' => out.push_str("\\n"),
43            '\r' => out.push_str("\\r"),
44            '\t' => out.push_str("\\t"),
45            '\u{08}' => out.push_str("\\b"),
46            '\u{0c}' => out.push_str("\\f"),
47            c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
48            c => out.push(c),
49        }
50    }
51    out
52}
53
54/// Float formatting compatible with v1: text used Rust `{}`; JSON used
55/// serde_json (ryu). Both print 3.14 as "3.14"; ryu prints whole floats
56/// as "3.0" where `{}` prints "3". Force the ".0" for whole floats.
57fn fmt_float(f: f64) -> String {
58    if f.is_finite() && f.fract() == 0.0 && f.abs() < 1e15 {
59        format!("{f:.1}")
60    } else {
61        format!("{f}")
62    }
63}
64
65pub fn term_to_json(m: &Machine, w: Word) -> String {
66    term_to_json_v(m, w, &mut Vec::new())
67}
68
69/// `visiting` holds the heap indices of STR/LST cells currently being
70/// expanded: re-encountering one means the term is cyclic (legal
71/// without occurs check), and the cycle is cut by rendering a variable
72/// — exactly v1's cycle-safe `apply()` behavior (`X = f(X)` renders as
73/// `f(_N)`).
74fn term_to_json_v(m: &Machine, w: Word, visiting: &mut Vec<usize>) -> String {
75    let w = m.deref(w);
76    match tag_of(w) {
77        TAG_ATOM => format!("\"{}\"", json_escape(m.atoms.resolve(atom_id(w)))),
78        TAG_INT => int_value(w).to_string(),
79        TAG_BIG => (m.heap[payload(w) as usize] as i64).to_string(),
80        TAG_FLT => fmt_float(f64::from_bits(m.heap[payload(w) as usize])),
81        TAG_REF => format!("\"_{}\"", payload(w)),
82        TAG_STR => {
83            let idx = payload(w) as usize;
84            if visiting.contains(&idx) {
85                return format!("\"_{idx}\""); // cycle cut (v1 behavior)
86            }
87            visiting.push(idx);
88            let (f, n) = unpack_functor(m.heap[idx]);
89            let args: Vec<String> = (0..n as usize)
90                .map(|i| term_to_json_v(m, m.heap[idx + 1 + i], visiting))
91                .collect();
92            visiting.pop();
93            // serde_json sorted keys: "args" < "functor"
94            format!(
95                "{{\"args\":[{}],\"functor\":\"{}\"}}",
96                args.join(","),
97                json_escape(m.atoms.resolve(f))
98            )
99        }
100        TAG_LST => {
101            let idx = payload(w) as usize;
102            if visiting.contains(&idx) {
103                return format!("\"_{idx}\"");
104            }
105            visiting.push(idx);
106            let (elements, tail) = collect_list_v(m, w, visiting);
107            let items: Vec<String> = elements
108                .iter()
109                .map(|e| term_to_json_v(m, *e, visiting))
110                .collect();
111            let out = match tail {
112                None => format!("[{}]", items.join(",")),
113                // serde_json sorted keys: "list" < "tail"
114                Some(t) => format!(
115                    "{{\"list\":[{}],\"tail\":{}}}",
116                    items.join(","),
117                    term_to_json_v(m, t, visiting)
118                ),
119            };
120            visiting.pop();
121            out
122        }
123        _ => unreachable!("bad tag"),
124    }
125}
126
127/// v1's infix-operator set for human-readable compound rendering.
128const INFIX: &[&str] = &[
129    "+", "-", "*", "/", "mod", "is", "=", "\\=", "<", ">", "=<", ">=", "=:=", "=\\=",
130];
131
132pub fn term_to_string(m: &Machine, w: Word) -> String {
133    term_to_string_v(m, w, false, &mut Vec::new())
134}
135
136/// `writeq/1` rendering: like [`term_to_string`] but atoms that wouldn't read
137/// back unquoted are single-quoted (issue #33). Used only by `writeq/1`.
138pub fn term_to_string_quoted(m: &Machine, w: Word) -> String {
139    term_to_string_v(m, w, true, &mut Vec::new())
140}
141
142/// An atom prints WITHOUT quotes under `writeq` iff it is a solo atom
143/// (`[]`/`!`/`;`/`{}`), an alphanumeric atom (lowercase letter then
144/// letters/digits/`_`), or a symbolic atom (all chars from the ISO symbol
145/// set). Everything else — including the empty atom and anything with spaces
146/// or a leading capital — needs quoting so it reads back as the same atom.
147fn atom_is_unquoted(s: &str) -> bool {
148    if matches!(s, "[]" | "!" | ";" | "{}") {
149        return true;
150    }
151    let bytes = s.as_bytes();
152    if bytes.is_empty() {
153        return false;
154    }
155    if bytes[0].is_ascii_lowercase()
156        && bytes
157            .iter()
158            .all(|b| b.is_ascii_alphanumeric() || *b == b'_')
159    {
160        return true;
161    }
162    const SYM: &[u8] = b"+-*/\\^<>=~:.?@#&$";
163    bytes.iter().all(|b| SYM.contains(b))
164}
165
166/// Render an atom for `writeq`: bare when [`atom_is_unquoted`], else
167/// single-quoted with `'`, `\`, and control chars escaped so it round-trips.
168fn quote_atom(s: &str) -> String {
169    if atom_is_unquoted(s) {
170        return s.to_string();
171    }
172    let mut out = String::with_capacity(s.len() + 2);
173    out.push('\'');
174    for c in s.chars() {
175        match c {
176            '\'' => out.push_str("\\'"),
177            '\\' => out.push_str("\\\\"),
178            '\n' => out.push_str("\\n"),
179            '\t' => out.push_str("\\t"),
180            c => out.push(c),
181        }
182    }
183    out.push('\'');
184    out
185}
186
187/// Render an atom's name, quoting it when `quoted` (writeq) requires it.
188fn atom_name(name: &str, quoted: bool) -> String {
189    if quoted {
190        quote_atom(name)
191    } else {
192        name.to_string()
193    }
194}
195
196fn term_to_string_v(m: &Machine, w: Word, quoted: bool, visiting: &mut Vec<usize>) -> String {
197    let w = m.deref(w);
198    match tag_of(w) {
199        TAG_ATOM => atom_name(m.atoms.resolve(atom_id(w)), quoted),
200        TAG_INT => int_value(w).to_string(),
201        TAG_BIG => (m.heap[payload(w) as usize] as i64).to_string(),
202        // `fmt_float` forces the trailing ".0" on whole-valued floats so the
203        // written form reads back as a float (issue #32); raw `{}` would print
204        // `write(2.0)` as `2`, indistinguishable from the integer.
205        TAG_FLT => fmt_float(f64::from_bits(m.heap[payload(w) as usize])),
206        TAG_REF => format!("_{}", payload(w)),
207        TAG_STR => {
208            let idx = payload(w) as usize;
209            if visiting.contains(&idx) {
210                return format!("_{idx}"); // cycle cut (v1 behavior)
211            }
212            visiting.push(idx);
213            let (f, n) = unpack_functor(m.heap[idx]);
214            let name = m.atoms.resolve(f).to_string();
215            // INFIX operators are symbolic/alphanumeric atoms — never quoted —
216            // so the infix branch is shared by write and writeq unchanged.
217            let out = if n == 2 && INFIX.contains(&name.as_str()) {
218                format!(
219                    "{} {} {}",
220                    term_to_string_v(m, m.heap[idx + 1], quoted, visiting),
221                    name,
222                    term_to_string_v(m, m.heap[idx + 2], quoted, visiting)
223                )
224            } else {
225                let args: Vec<String> = (0..n as usize)
226                    .map(|i| term_to_string_v(m, m.heap[idx + 1 + i], quoted, visiting))
227                    .collect();
228                format!("{}({})", atom_name(&name, quoted), args.join(", "))
229            };
230            visiting.pop();
231            out
232        }
233        TAG_LST => {
234            let idx = payload(w) as usize;
235            if visiting.contains(&idx) {
236                return format!("_{idx}");
237            }
238            visiting.push(idx);
239            let (elements, tail) = collect_list_v(m, w, visiting);
240            let items: Vec<String> = elements
241                .iter()
242                .map(|e| term_to_string_v(m, *e, quoted, visiting))
243                .collect();
244            let out = match tail {
245                None => format!("[{}]", items.join(", ")),
246                Some(t) => format!(
247                    "[{}|{}]",
248                    items.join(", "),
249                    term_to_string_v(m, t, quoted, visiting)
250                ),
251            };
252            visiting.pop();
253            out
254        }
255        _ => unreachable!("bad tag"),
256    }
257}
258
259/// v1's `format_term` rendering: plain functional notation (no infix),
260/// atoms unquoted, vars `_<idx>`, lists `[a, b|T]`. This is the byte
261/// contract for error messages ("Runtime error: error(...)").
262pub fn format_term(m: &Machine, w: Word, out: &mut String) {
263    format_term_v(m, w, out, &mut Vec::new())
264}
265
266fn format_term_v(m: &Machine, w: Word, out: &mut String, visiting: &mut Vec<usize>) {
267    let w = m.deref(w);
268    match tag_of(w) {
269        TAG_ATOM => out.push_str(m.atoms.resolve(atom_id(w))),
270        TAG_INT => out.push_str(&int_value(w).to_string()),
271        TAG_BIG => out.push_str(&(m.heap[payload(w) as usize] as i64).to_string()),
272        // Route through `fmt_float` so a whole-valued float embedded in an
273        // error term keeps its ".0" too (issue #32): a `2.0` culprit must not
274        // print as `2`, indistinguishable from the integer.
275        TAG_FLT => out.push_str(&fmt_float(f64::from_bits(m.heap[payload(w) as usize]))),
276        TAG_REF => {
277            out.push('_');
278            out.push_str(&payload(w).to_string());
279        }
280        TAG_STR => {
281            let idx = payload(w) as usize;
282            if visiting.contains(&idx) {
283                out.push('_');
284                out.push_str(&idx.to_string());
285                return;
286            }
287            visiting.push(idx);
288            let (f, n) = unpack_functor(m.heap[idx]);
289            out.push_str(m.atoms.resolve(f));
290            out.push('(');
291            for i in 0..n as usize {
292                if i > 0 {
293                    out.push_str(", ");
294                }
295                format_term_v(m, m.heap[idx + 1 + i], out, visiting);
296            }
297            out.push(')');
298            visiting.pop();
299        }
300        TAG_LST => {
301            let idx = payload(w) as usize;
302            if visiting.contains(&idx) {
303                out.push('_');
304                out.push_str(&idx.to_string());
305                return;
306            }
307            visiting.push(idx);
308            out.push('[');
309            let (elements, tail) = collect_list_v(m, w, visiting);
310            for (i, e) in elements.iter().enumerate() {
311                if i > 0 {
312                    out.push_str(", ");
313                }
314                format_term_v(m, *e, out, visiting);
315            }
316            if let Some(t) = tail {
317                out.push('|');
318                format_term_v(m, t, out, visiting);
319            }
320            out.push(']');
321            visiting.pop();
322        }
323        _ => unreachable!("bad tag"),
324    }
325}
326
327/// Walk a LST chain. Returns the element words and `None` if the list
328/// is proper (nil-terminated) or `Some(tail)` for a partial list. A
329/// spine cell already being rendered (cyclic list) terminates the walk
330/// as an improper tail so the cycle cut renders as a variable.
331fn collect_list_v(m: &Machine, w: Word, visiting: &[usize]) -> (Vec<Word>, Option<Word>) {
332    let mut elements = Vec::new();
333    let mut cur = m.deref(w);
334    let mut seen: Vec<usize> = Vec::new();
335    loop {
336        match tag_of(cur) {
337            TAG_LST => {
338                let idx = payload(cur) as usize;
339                if seen.contains(&idx) || (visiting.contains(&idx) && !elements.is_empty()) {
340                    return (elements, Some(cur));
341                }
342                seen.push(idx);
343                elements.push(m.heap[idx]);
344                cur = m.deref(m.heap[idx + 1]);
345            }
346            TAG_ATOM if atom_id(cur) == ATOM_NIL => return (elements, None),
347            _ => return (elements, Some(cur)),
348        }
349    }
350}
351
352#[cfg(test)]
353mod tests {
354    use super::*;
355    use plg_shared::StringInterner;
356
357    fn machine() -> Box<Machine> {
358        let mut atoms = StringInterner::new();
359        atoms.intern("foo");
360        atoms.intern("bar");
361        Machine::new(atoms, Vec::new())
362    }
363
364    #[test]
365    fn json_escape_matches_serde() {
366        assert_eq!(json_escape("a\"b\\c\nd"), "a\\\"b\\\\c\\nd");
367        assert_eq!(json_escape("\u{01}"), "\\u0001");
368    }
369
370    #[test]
371    fn atoms_ints_render() {
372        let m = machine();
373        let foo = m.atoms.lookup("foo").unwrap();
374        assert_eq!(term_to_json(&m, make_atom(foo)), "\"foo\"");
375        assert_eq!(term_to_json(&m, make_int(-7)), "-7");
376        assert_eq!(term_to_string(&m, make_int(-7)), "-7");
377    }
378
379    #[test]
380    fn compound_renders_sorted_keys() {
381        let mut m = machine();
382        let foo = m.atoms.lookup("foo").unwrap();
383        let bar = m.atoms.lookup("bar").unwrap();
384        let idx = m.heap.len();
385        m.heap.push(pack_functor(foo, 2));
386        m.heap.push(make_atom(bar));
387        m.heap.push(make_int(1));
388        let w = make(TAG_STR, idx as u64);
389        assert_eq!(
390            term_to_json(&m, w),
391            "{\"args\":[\"bar\",1],\"functor\":\"foo\"}"
392        );
393        assert_eq!(term_to_string(&m, w), "foo(bar, 1)");
394    }
395
396    #[test]
397    fn whole_floats_keep_decimal_point_in_text() {
398        // Regression for #32: write/1 / binding text uses term_to_string, which
399        // must render 2.0 as "2.0" (not "2") so it reads back as a float.
400        let mut m = machine();
401        let push_flt = |m: &mut Machine, f: f64| {
402            let idx = m.heap.len();
403            m.heap.push(f.to_bits());
404            make(TAG_FLT, idx as u64)
405        };
406        let two = push_flt(&mut m, 2.0);
407        assert_eq!(term_to_string(&m, two), "2.0");
408        assert_eq!(term_to_json(&m, two), "2.0");
409        // format_term (error-message byte contract) keeps the ".0" too, so a
410        // float culprit in an error term doesn't read back as an integer.
411        let mut em = String::new();
412        format_term(&m, two, &mut em);
413        assert_eq!(em, "2.0");
414        let big = push_flt(&mut m, 1024.0);
415        assert_eq!(term_to_string(&m, big), "1024.0");
416        // Non-whole floats are unaffected.
417        let half = push_flt(&mut m, 3.5);
418        assert_eq!(term_to_string(&m, half), "3.5");
419    }
420
421    #[test]
422    fn writeq_quotes_only_when_needed() {
423        // Regression for #33: term_to_string_quoted (writeq/1) single-quotes
424        // atoms that wouldn't read back unquoted, leaving the rest bare.
425        let mut m = machine();
426        let atom = |m: &mut Machine, s: &str| make_atom(m.atoms.intern(s));
427
428        // Bare: alphanumeric, symbolic, and solo atoms.
429        for s in ["foo", "fooBar", "+", "=..", "[]", "!", ";"] {
430            let w = atom(&mut m, s);
431            assert_eq!(term_to_string_quoted(&m, w), s, "{s} must stay unquoted");
432        }
433        // Quoted: spaces, leading capital, empty, embedded quote.
434        let w = atom(&mut m, "hello world");
435        assert_eq!(term_to_string_quoted(&m, w), "'hello world'");
436        let w = atom(&mut m, "Abc");
437        assert_eq!(term_to_string_quoted(&m, w), "'Abc'");
438        let w = atom(&mut m, "");
439        assert_eq!(term_to_string_quoted(&m, w), "''");
440        let w = atom(&mut m, "it's");
441        assert_eq!(term_to_string_quoted(&m, w), "'it\\'s'");
442
443        // write/1 (unquoted) is unaffected — same atom prints bare.
444        let w = atom(&mut m, "hello world");
445        assert_eq!(term_to_string(&m, w), "hello world");
446
447        // Functor names are quoted too, args recurse.
448        let inner = atom(&mut m, "a b");
449        let f = m.atoms.intern("my pred");
450        let idx = m.heap.len();
451        m.heap.push(pack_functor(f, 1));
452        m.heap.push(inner);
453        let s = make(TAG_STR, idx as u64);
454        assert_eq!(term_to_string_quoted(&m, s), "'my pred'('a b')");
455    }
456
457    #[test]
458    fn proper_and_partial_lists() {
459        let mut m = machine();
460        let nil = make_atom(ATOM_NIL);
461        let i2 = m.heap.len();
462        m.heap.push(make_int(2));
463        m.heap.push(nil);
464        let l2 = make(TAG_LST, i2 as u64);
465        let i1 = m.heap.len();
466        m.heap.push(make_int(1));
467        m.heap.push(l2);
468        let l1 = make(TAG_LST, i1 as u64);
469        assert_eq!(term_to_json(&m, l1), "[1,2]");
470        assert_eq!(term_to_string(&m, l1), "[1, 2]");
471
472        let v = m.new_var();
473        let ip = m.heap.len();
474        m.heap.push(make_int(1));
475        m.heap.push(v);
476        let lp = make(TAG_LST, ip as u64);
477        let json = term_to_json(&m, lp);
478        assert!(json.starts_with("{\"list\":[1],\"tail\":\"_"), "{json}");
479        assert!(term_to_string(&m, lp).starts_with("[1|_"));
480    }
481}