Skip to main content

plg_runtime/
render.rs

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