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, &mut Vec::new())
134}
135
136fn term_to_string_v(m: &Machine, w: Word, visiting: &mut Vec<usize>) -> String {
137    let w = m.deref(w);
138    match tag_of(w) {
139        TAG_ATOM => m.atoms.resolve(atom_id(w)).to_string(),
140        TAG_INT => int_value(w).to_string(),
141        TAG_BIG => (m.heap[payload(w) as usize] as i64).to_string(),
142        TAG_FLT => format!("{}", f64::from_bits(m.heap[payload(w) as usize])),
143        TAG_REF => format!("_{}", payload(w)),
144        TAG_STR => {
145            let idx = payload(w) as usize;
146            if visiting.contains(&idx) {
147                return format!("_{idx}"); // cycle cut (v1 behavior)
148            }
149            visiting.push(idx);
150            let (f, n) = unpack_functor(m.heap[idx]);
151            let name = m.atoms.resolve(f).to_string();
152            let out = if n == 2 && INFIX.contains(&name.as_str()) {
153                format!(
154                    "{} {} {}",
155                    term_to_string_v(m, m.heap[idx + 1], visiting),
156                    name,
157                    term_to_string_v(m, m.heap[idx + 2], visiting)
158                )
159            } else {
160                let args: Vec<String> = (0..n as usize)
161                    .map(|i| term_to_string_v(m, m.heap[idx + 1 + i], visiting))
162                    .collect();
163                format!("{}({})", name, args.join(", "))
164            };
165            visiting.pop();
166            out
167        }
168        TAG_LST => {
169            let idx = payload(w) as usize;
170            if visiting.contains(&idx) {
171                return format!("_{idx}");
172            }
173            visiting.push(idx);
174            let (elements, tail) = collect_list_v(m, w, visiting);
175            let items: Vec<String> = elements
176                .iter()
177                .map(|e| term_to_string_v(m, *e, visiting))
178                .collect();
179            let out = match tail {
180                None => format!("[{}]", items.join(", ")),
181                Some(t) => format!(
182                    "[{}|{}]",
183                    items.join(", "),
184                    term_to_string_v(m, t, visiting)
185                ),
186            };
187            visiting.pop();
188            out
189        }
190        _ => unreachable!("bad tag"),
191    }
192}
193
194/// v1's `format_term` rendering: plain functional notation (no infix),
195/// atoms unquoted, vars `_<idx>`, lists `[a, b|T]`. This is the byte
196/// contract for error messages ("Runtime error: error(...)").
197pub fn format_term(m: &Machine, w: Word, out: &mut String) {
198    format_term_v(m, w, out, &mut Vec::new())
199}
200
201fn format_term_v(m: &Machine, w: Word, out: &mut String, visiting: &mut Vec<usize>) {
202    let w = m.deref(w);
203    match tag_of(w) {
204        TAG_ATOM => out.push_str(m.atoms.resolve(atom_id(w))),
205        TAG_INT => out.push_str(&int_value(w).to_string()),
206        TAG_BIG => out.push_str(&(m.heap[payload(w) as usize] as i64).to_string()),
207        TAG_FLT => out.push_str(&f64::from_bits(m.heap[payload(w) as usize]).to_string()),
208        TAG_REF => {
209            out.push('_');
210            out.push_str(&payload(w).to_string());
211        }
212        TAG_STR => {
213            let idx = payload(w) as usize;
214            if visiting.contains(&idx) {
215                out.push('_');
216                out.push_str(&idx.to_string());
217                return;
218            }
219            visiting.push(idx);
220            let (f, n) = unpack_functor(m.heap[idx]);
221            out.push_str(m.atoms.resolve(f));
222            out.push('(');
223            for i in 0..n as usize {
224                if i > 0 {
225                    out.push_str(", ");
226                }
227                format_term_v(m, m.heap[idx + 1 + i], out, visiting);
228            }
229            out.push(')');
230            visiting.pop();
231        }
232        TAG_LST => {
233            let idx = payload(w) as usize;
234            if visiting.contains(&idx) {
235                out.push('_');
236                out.push_str(&idx.to_string());
237                return;
238            }
239            visiting.push(idx);
240            out.push('[');
241            let (elements, tail) = collect_list_v(m, w, visiting);
242            for (i, e) in elements.iter().enumerate() {
243                if i > 0 {
244                    out.push_str(", ");
245                }
246                format_term_v(m, *e, out, visiting);
247            }
248            if let Some(t) = tail {
249                out.push('|');
250                format_term_v(m, t, out, visiting);
251            }
252            out.push(']');
253            visiting.pop();
254        }
255        _ => unreachable!("bad tag"),
256    }
257}
258
259/// Walk a LST chain. Returns the element words and `None` if the list
260/// is proper (nil-terminated) or `Some(tail)` for a partial list. A
261/// spine cell already being rendered (cyclic list) terminates the walk
262/// as an improper tail so the cycle cut renders as a variable.
263fn collect_list_v(m: &Machine, w: Word, visiting: &[usize]) -> (Vec<Word>, Option<Word>) {
264    let mut elements = Vec::new();
265    let mut cur = m.deref(w);
266    let mut seen: Vec<usize> = Vec::new();
267    loop {
268        match tag_of(cur) {
269            TAG_LST => {
270                let idx = payload(cur) as usize;
271                if seen.contains(&idx) || (visiting.contains(&idx) && !elements.is_empty()) {
272                    return (elements, Some(cur));
273                }
274                seen.push(idx);
275                elements.push(m.heap[idx]);
276                cur = m.deref(m.heap[idx + 1]);
277            }
278            TAG_ATOM if atom_id(cur) == ATOM_NIL => return (elements, None),
279            _ => return (elements, Some(cur)),
280        }
281    }
282}
283
284#[cfg(test)]
285mod tests {
286    use super::*;
287    use plg_shared::StringInterner;
288
289    fn machine() -> Box<Machine> {
290        let mut atoms = StringInterner::new();
291        atoms.intern("foo");
292        atoms.intern("bar");
293        Machine::new(atoms, Vec::new())
294    }
295
296    #[test]
297    fn json_escape_matches_serde() {
298        assert_eq!(json_escape("a\"b\\c\nd"), "a\\\"b\\\\c\\nd");
299        assert_eq!(json_escape("\u{01}"), "\\u0001");
300    }
301
302    #[test]
303    fn atoms_ints_render() {
304        let m = machine();
305        let foo = m.atoms.lookup("foo").unwrap();
306        assert_eq!(term_to_json(&m, make_atom(foo)), "\"foo\"");
307        assert_eq!(term_to_json(&m, make_int(-7)), "-7");
308        assert_eq!(term_to_string(&m, make_int(-7)), "-7");
309    }
310
311    #[test]
312    fn compound_renders_sorted_keys() {
313        let mut m = machine();
314        let foo = m.atoms.lookup("foo").unwrap();
315        let bar = m.atoms.lookup("bar").unwrap();
316        let idx = m.heap.len();
317        m.heap.push(pack_functor(foo, 2));
318        m.heap.push(make_atom(bar));
319        m.heap.push(make_int(1));
320        let w = make(TAG_STR, idx as u64);
321        assert_eq!(
322            term_to_json(&m, w),
323            "{\"args\":[\"bar\",1],\"functor\":\"foo\"}"
324        );
325        assert_eq!(term_to_string(&m, w), "foo(bar, 1)");
326    }
327
328    #[test]
329    fn proper_and_partial_lists() {
330        let mut m = machine();
331        let nil = make_atom(ATOM_NIL);
332        let i2 = m.heap.len();
333        m.heap.push(make_int(2));
334        m.heap.push(nil);
335        let l2 = make(TAG_LST, i2 as u64);
336        let i1 = m.heap.len();
337        m.heap.push(make_int(1));
338        m.heap.push(l2);
339        let l1 = make(TAG_LST, i1 as u64);
340        assert_eq!(term_to_json(&m, l1), "[1,2]");
341        assert_eq!(term_to_string(&m, l1), "[1, 2]");
342
343        let v = m.new_var();
344        let ip = m.heap.len();
345        m.heap.push(make_int(1));
346        m.heap.push(v);
347        let lp = make(TAG_LST, ip as u64);
348        let json = term_to_json(&m, lp);
349        assert!(json.starts_with("{\"list\":[1],\"tail\":\"_"), "{json}");
350        assert!(term_to_string(&m, lp).starts_with("[1|_"));
351    }
352}