Skip to main content

sui_bytecode/
render.rs

1//! The VM's normalized differential render — the third member of a
2//! format-locked family.
3//!
4//! # Why this is not `to_string_keyed` + `string_keyed_to_json`
5//!
6//! Two renders for a VM value already existed and **neither can be used for a
7//! differential**:
8//!
9//! - [`VMValue::to_string_keyed`](crate::VMValue::to_string_keyed) maps an
10//!   *unforced* thunk to `StringKeyedValue::Lambda`. A value the VM failed to
11//!   force therefore renders exactly like a value that legitimately is a
12//!   function — and on the other side of the comparison the tree-walker's real
13//!   lambda renders `<<lambda>>` too. The two compare **equal**, so a fixture
14//!   the VM could not evaluate reports agreement.
15//! - `string_keyed_to_json` (private, in the `sui` binary at `src/main.rs`)
16//!   renders a thunk as the literal string `"<thunk>"` and has neither CppNix's
17//!   `outPath`/`__toString` rule nor any forcing. It disagrees with the
18//!   walker's `Value::to_json` on both counts. It is **not** lifted here — see
19//!   the divergence note below.
20//!
21//! # What this does instead
22//!
23//! [`render_vm`] emits the byte-identical form of
24//! [`sui_eval::render::render_tree`] — CppNix float format, attrs sorted by
25//! **resolved name**, the walker's string escaping, raw (unquoted) paths, and
26//! the same 128-deep `<...>` cap — with one deliberate difference:
27//!
28//! **A residual thunk is an `Err`, never a placeholder.**
29//!
30//! [`VM::execute`](crate::VM::execute) already deep-forces its result, so a
31//! `Thunk` still standing at render time means forcing genuinely did not
32//! happen. Rendering that as `<<lambda>>` (what `to_string_keyed` does) or as
33//! `"<thunk>"` (what the binary's helper does) would let it compare equal to
34//! the other engine's placeholder. Refusing is the only rendering that cannot
35//! launder a non-answer into an agreement.
36//!
37//! # The residual placeholder, stated rather than hidden
38//!
39//! `<<lambda>>` and `<<builtin n>>` are still emitted for values that really
40//! **are** functions, exactly as `render_tree` does for the walker. Two engines
41//! that both produce a function agree on the render without agreeing on the
42//! body. That is a property of the walker's own differential render, not
43//! something introduced here, and it is why `lang_corpus_vm.rs` additionally
44//! pins that **no** agreeing corpus row contains a placeholder at all.
45//!
46//! # Divergence recorded, not papered over
47//!
48//! The binary's `string_keyed_to_json` and the walker's `Value::to_json`
49//! disagree: the walker implements CppNix's rule that an attrset carrying
50//! `outPath` / `__toString` serializes as that string, and it forces thunks;
51//! the binary's helper does neither. This module sides with **neither** — it is
52//! not a JSON renderer at all, it is the walker's *differential* render, which
53//! is a third form that predates both and is the one already used to compare
54//! two engines (`sui-ir/tests/common/render.rs`). The `outPath` rule is
55//! therefore **out of scope here and still unreconciled between those two JSON
56//! paths**; nothing in this file makes that better or worse.
57
58use std::collections::BTreeMap;
59
60use crate::intern::Interner;
61use crate::value::{ThunkState, VMValue};
62
63/// Render-recursion depth cap — must equal `sui_eval::render::MAX_RENDER_DEPTH`
64/// and `sui_ir::render::MAX_RENDER_DEPTH`.
65pub const MAX_RENDER_DEPTH: usize = 128;
66
67/// The identical marker every engine emits past [`MAX_RENDER_DEPTH`].
68pub const DEEP_SENTINEL: &str = "<...>";
69
70/// The walker's `Display` string escaping — must equal
71/// `sui_eval::render::escape_str`.
72#[must_use]
73pub fn escape_str(s: &str) -> String {
74    s.replace('\\', "\\\\").replace('"', "\\\"")
75}
76
77/// Render a [`VMValue`] to the normalized differential form.
78///
79/// # Errors
80///
81/// - A thunk that survived [`VM::execute`](crate::VM::execute)'s deep-force.
82///   Deliberately an error and not a placeholder: see the module docs.
83/// - An interner that resolves two distinct symbols to the same name, which
84///   would otherwise silently drop an attribute during the sort.
85pub fn render_vm(v: &VMValue, interner: &Interner) -> Result<String, String> {
86    render_at(v, interner, 0)
87}
88
89fn render_at(v: &VMValue, interner: &Interner, depth: usize) -> Result<String, String> {
90    if depth >= MAX_RENDER_DEPTH {
91        return Ok(DEEP_SENTINEL.to_string());
92    }
93    Ok(match v {
94        VMValue::Null => "null".to_string(),
95        VMValue::Bool(b) => b.to_string(),
96        VMValue::Int(n) => n.to_string(),
97        VMValue::Float(f) => sui_compat::versions::cppnix_format_float(*f),
98        VMValue::String(s) => {
99            let mut out = String::from("\"");
100            out.push_str(&escape_str(s));
101            out.push('"');
102            out
103        }
104        VMValue::Path(p) => p.clone(),
105        VMValue::List(items) => {
106            let mut out = String::from("[ ");
107            for item in items {
108                out.push_str(&render_at(item, interner, depth + 1)?);
109                out.push(' ');
110            }
111            out.push(']');
112            out
113        }
114        VMValue::Attrs(attrs) => {
115            // The VM keys attrsets by `Symbol`, whose `Ord` is *interning
116            // order* — the order names were first seen, which varies with the
117            // program text. The walker sorts by NAME. Iterating the VM's
118            // `BTreeMap<Symbol, _>` directly would therefore emit a different
119            // key order for the same attrset and report a divergence on every
120            // multi-key set in the corpus: ~all of it, none of it real.
121            let mut by_name: BTreeMap<String, &VMValue> = BTreeMap::new();
122            for (sym, val) in attrs {
123                by_name.insert(interner.resolve(*sym).to_string(), val);
124            }
125            // Anti-vacuity for the re-key: a `BTreeMap` insert on a duplicate
126            // name silently DROPS an attribute, so a broken interner would
127            // shrink the set and still render cleanly. Two symbols resolving to
128            // one name is an interner bug; refuse rather than render fewer
129            // attrs than the VM produced.
130            if by_name.len() != attrs.len() {
131                return Err(format!(
132                    "interner resolved {} symbols to {} distinct names — an \
133                     attribute would be silently dropped by the name sort",
134                    attrs.len(),
135                    by_name.len()
136                ));
137            }
138            let mut out = String::from("{ ");
139            for (k, val) in by_name {
140                out.push_str(&k);
141                out.push_str(" = ");
142                out.push_str(&render_at(val, interner, depth + 1)?);
143                out.push_str("; ");
144            }
145            out.push('}');
146            out
147        }
148        VMValue::Closure(_) => "<<lambda>>".to_string(),
149        VMValue::Builtin(b) => {
150            let mut out = String::from("<<builtin ");
151            out.push_str(b.name);
152            out.push_str(">>");
153            out
154        }
155        VMValue::HigherOrderBuiltin(h) => format!("<<builtin {:?}>>", h.op),
156        VMValue::Thunk(t) => {
157            // Read the memoized value without consuming it: `Cell::take` leaves
158            // `None` behind, so the state must be put back or the thunk is
159            // corrupted for the next reader.
160            let state = t.state.take();
161            let done = match &state {
162                Some(ThunkState::Done(inner)) => Some(inner.clone()),
163                _ => None,
164            };
165            t.state.set(state);
166            match done {
167                Some(inner) => render_at(&inner, interner, depth + 1)?,
168                // THE ANTI-PLACEHOLDER RULE. `VM::execute` deep-forces before
169                // returning, so an unforced thunk here is a real failure to
170                // evaluate. `to_string_keyed` would render it `<<lambda>>` and
171                // it would compare EQUAL to the walker's lambda; the binary's
172                // JSON helper would render `"<thunk>"` and it would compare
173                // equal to another `"<thunk>"`. Both launder a non-answer into
174                // agreement. An error cannot.
175                None => {
176                    return Err(
177                        "unforced thunk survived VM deep-force — refusing to render a \
178                         placeholder, because a placeholder compares EQUAL to the other \
179                         engine's placeholder and reports agreement where neither engine \
180                         produced a value"
181                            .to_string(),
182                    );
183                }
184            }
185        }
186    })
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192    use crate::value::VMThunk;
193
194    fn interner() -> Interner {
195        Interner::new()
196    }
197
198    #[test]
199    fn scalars_match_the_walkers_forms() {
200        let i = interner();
201        assert_eq!(render_vm(&VMValue::Null, &i).unwrap(), "null");
202        assert_eq!(render_vm(&VMValue::Bool(true), &i).unwrap(), "true");
203        assert_eq!(render_vm(&VMValue::Int(-3), &i).unwrap(), "-3");
204        assert_eq!(
205            render_vm(&VMValue::String("a\"b\\c".into()), &i).unwrap(),
206            "\"a\\\"b\\\\c\""
207        );
208        assert_eq!(render_vm(&VMValue::Path("/x/y".into()), &i).unwrap(), "/x/y");
209    }
210
211    #[test]
212    fn attrs_sort_by_resolved_name_not_symbol_id() {
213        // Intern in an order that makes symbol-id order DISAGREE with name
214        // order: "zzz" first, so its Symbol sorts before "aaa"'s.
215        let mut i = interner();
216        let z = i.intern("zzz");
217        let a = i.intern("aaa");
218        let mut attrs = BTreeMap::new();
219        attrs.insert(z, VMValue::Int(1));
220        attrs.insert(a, VMValue::Int(2));
221        // If this rendered in Symbol order it would read `{ zzz = 1; aaa = 2; }`
222        // and every multi-key corpus fixture would falsely diverge.
223        assert_eq!(
224            render_vm(&VMValue::Attrs(attrs), &i).unwrap(),
225            "{ aaa = 2; zzz = 1; }"
226        );
227    }
228
229    #[test]
230    fn an_unforced_thunk_is_an_error_not_a_placeholder() {
231        // The load-bearing test of this module. A pending thunk must NOT
232        // render as `<<lambda>>` / `"<thunk>"` — those compare equal to the
233        // other engine's placeholder.
234        let i = interner();
235        let t = VMThunk::new(std::rc::Rc::new(crate::chunk::Chunk::new()), Vec::new());
236        let err = render_vm(&VMValue::Thunk(t), &i).unwrap_err();
237        assert!(
238            err.contains("unforced thunk"),
239            "expected a refusal, got: {err}"
240        );
241    }
242
243    #[test]
244    fn a_forced_thunk_renders_its_value() {
245        let i = interner();
246        let t = VMThunk::new_done(VMValue::Int(7));
247        assert_eq!(render_vm(&VMValue::Thunk(t), &i).unwrap(), "7");
248    }
249
250    #[test]
251    fn the_depth_cap_matches_the_other_engines() {
252        // A mismatched cap would make deep values diverge for a reason that is
253        // purely about rendering.
254        assert_eq!(MAX_RENDER_DEPTH, sui_eval_render_depth_pin());
255        assert_eq!(DEEP_SENTINEL, "<...>");
256    }
257
258    /// The walker's constant, restated. `sui-eval` is only a dev-dependency
259    /// here (see `Cargo.toml`'s publish-cycle note), so this cannot read
260    /// `sui_eval::render::MAX_RENDER_DEPTH` from library code; the corpus test,
261    /// which does have `sui-eval`, asserts the two are equal for real.
262    fn sui_eval_render_depth_pin() -> usize {
263        128
264    }
265}