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