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}