plotnik_vm/engine/
verify.rs

1//! Debug-only type verification for materialized values.
2//!
3//! Verifies that materialized `Value` matches the declared `result_type` from bytecode.
4//! Zero-cost in release builds.
5
6use plotnik_bytecode::{Module, TypeId};
7#[cfg(debug_assertions)]
8use plotnik_bytecode::{StringsView, TypeData, TypeKind, TypesView};
9use plotnik_core::Colors;
10
11use super::Value;
12
13/// Debug-only type verification.
14///
15/// Panics with a pretty diagnostic if the value doesn't match the declared type.
16/// This is a no-op in release builds.
17///
18/// `declared_type` should be the `result_type` from the entrypoint that was executed.
19#[cfg(debug_assertions)]
20pub fn debug_verify_type(value: &Value, declared_type: TypeId, module: &Module, colors: Colors) {
21    let types = module.types();
22    let strings = module.strings();
23
24    let mut errors = Vec::new();
25    verify_type(
26        value,
27        declared_type,
28        &types,
29        &strings,
30        &mut String::new(),
31        &mut errors,
32    );
33    if !errors.is_empty() {
34        panic_with_mismatch(value, declared_type, &errors, module, colors);
35    }
36}
37
38/// No-op in release builds.
39#[cfg(not(debug_assertions))]
40#[inline(always)]
41pub fn debug_verify_type(
42    _value: &Value,
43    _declared_type: TypeId,
44    _module: &Module,
45    _colors: Colors,
46) {
47}
48
49/// Recursive type verification. Collects mismatch paths into `errors`.
50#[cfg(debug_assertions)]
51fn verify_type(
52    value: &Value,
53    declared: TypeId,
54    types: &TypesView<'_>,
55    strings: &StringsView<'_>,
56    path: &mut String,
57    errors: &mut Vec<String>,
58) {
59    let Some(type_def) = types.get(declared) else {
60        errors.push(format_error(
61            path,
62            &format!("unknown type id {}", declared.0),
63        ));
64        return;
65    };
66
67    match type_def.classify() {
68        TypeData::Primitive(kind) => match kind {
69            TypeKind::Void => {
70                if !matches!(value, Value::Null) {
71                    errors.push(format_error(
72                        path,
73                        &format!("type: void, value: {}", value_kind_name(value)),
74                    ));
75                }
76            }
77            TypeKind::Node => {
78                if !matches!(value, Value::Node(_)) {
79                    errors.push(format_error(
80                        path,
81                        &format!("type: Node, value: {}", value_kind_name(value)),
82                    ));
83                }
84            }
85            TypeKind::String => {
86                if !matches!(value, Value::String(_)) {
87                    errors.push(format_error(
88                        path,
89                        &format!("type: string, value: {}", value_kind_name(value)),
90                    ));
91                }
92            }
93            _ => unreachable!(),
94        },
95
96        TypeData::Wrapper { kind, inner } => match kind {
97            TypeKind::Alias => {
98                if !matches!(value, Value::Node(_)) {
99                    errors.push(format_error(
100                        path,
101                        &format!("type: Node (alias), value: {}", value_kind_name(value)),
102                    ));
103                }
104            }
105            TypeKind::Optional => {
106                if !matches!(value, Value::Null) {
107                    verify_type(value, inner, types, strings, path, errors);
108                }
109            }
110            TypeKind::ArrayZeroOrMore => match value {
111                Value::Array(items) => {
112                    for (i, item) in items.iter().enumerate() {
113                        let prev_len = path.len();
114                        path.push_str(&format!("[{}]", i));
115                        verify_type(item, inner, types, strings, path, errors);
116                        path.truncate(prev_len);
117                    }
118                }
119                _ => {
120                    errors.push(format_error(
121                        path,
122                        &format!("type: array, value: {}", value_kind_name(value)),
123                    ));
124                }
125            },
126            TypeKind::ArrayOneOrMore => match value {
127                Value::Array(items) => {
128                    if items.is_empty() {
129                        errors.push(format_error(
130                            path,
131                            "type: non-empty array, value: empty array",
132                        ));
133                    }
134                    for (i, item) in items.iter().enumerate() {
135                        let prev_len = path.len();
136                        path.push_str(&format!("[{}]", i));
137                        verify_type(item, inner, types, strings, path, errors);
138                        path.truncate(prev_len);
139                    }
140                }
141                _ => {
142                    errors.push(format_error(
143                        path,
144                        &format!("type: array, value: {}", value_kind_name(value)),
145                    ));
146                }
147            },
148            _ => unreachable!(),
149        },
150
151        TypeData::Composite { kind, .. } => match kind {
152            TypeKind::Struct => match value {
153                Value::Object(fields) => {
154                    for member in types.members_of(&type_def) {
155                        let field_name = strings.get(member.name);
156                        let (inner_type, is_optional) = types.unwrap_optional(member.type_id);
157
158                        let field_value = fields.iter().find(|(k, _)| k == field_name);
159                        match field_value {
160                            Some((_, v)) => {
161                                if is_optional && matches!(v, Value::Null) {
162                                    continue;
163                                }
164                                let prev_len = path.len();
165                                path.push('.');
166                                path.push_str(field_name);
167                                verify_type(v, inner_type, types, strings, path, errors);
168                                path.truncate(prev_len);
169                            }
170                            None => {
171                                if !is_optional {
172                                    errors.push(format!(
173                                        "{}: required field missing",
174                                        append_path(path, field_name)
175                                    ));
176                                }
177                            }
178                        }
179                    }
180                }
181                _ => {
182                    errors.push(format_error(
183                        path,
184                        &format!("type: object, value: {}", value_kind_name(value)),
185                    ));
186                }
187            },
188            TypeKind::Enum => match value {
189                Value::Tagged { tag, data } => {
190                    let variant = types
191                        .members_of(&type_def)
192                        .find(|m| strings.get(m.name) == tag);
193
194                    match variant {
195                        Some(member) => {
196                            let is_void = types.get(member.type_id).is_some_and(|d| {
197                                matches!(d.classify(), TypeData::Primitive(TypeKind::Void))
198                            });
199
200                            if is_void {
201                                if data.is_some() {
202                                    errors.push(format!(
203                                        "{}: void variant '{}' should have no $data",
204                                        append_path(path, "$data"),
205                                        tag
206                                    ));
207                                }
208                            } else {
209                                match data {
210                                    Some(d) => {
211                                        let prev_len = path.len();
212                                        path.push_str(".$data");
213                                        verify_type(
214                                            d,
215                                            member.type_id,
216                                            types,
217                                            strings,
218                                            path,
219                                            errors,
220                                        );
221                                        path.truncate(prev_len);
222                                    }
223                                    None => {
224                                        errors.push(format!(
225                                            "{}: non-void variant '{}' should have $data",
226                                            append_path(path, "$data"),
227                                            tag
228                                        ));
229                                    }
230                                }
231                            }
232                        }
233                        None => {
234                            errors.push(format!(
235                                "{}: unknown variant '{}'",
236                                append_path(path, "$tag"),
237                                tag
238                            ));
239                        }
240                    }
241                }
242                _ => {
243                    errors.push(format_error(
244                        path,
245                        &format!("type: tagged union, value: {}", value_kind_name(value)),
246                    ));
247                }
248            },
249            _ => unreachable!(),
250        },
251    }
252}
253
254/// Get a display name for the value's kind.
255#[cfg(debug_assertions)]
256fn value_kind_name(value: &Value) -> &'static str {
257    match value {
258        Value::Null => "null",
259        Value::String(_) => "string",
260        Value::Node(_) => "Node",
261        Value::Array(_) => "array",
262        Value::Object(_) => "object",
263        Value::Tagged { .. } => "tagged union",
264    }
265}
266
267/// Format path for error message. Leading dot is stripped.
268#[cfg(debug_assertions)]
269fn format_path(path: &str) -> String {
270    path.strip_prefix('.').unwrap_or(path).to_string()
271}
272
273/// Format error with optional path prefix.
274#[cfg(debug_assertions)]
275fn format_error(path: &str, msg: &str) -> String {
276    let p = format_path(path);
277    if p.is_empty() {
278        msg.to_string()
279    } else {
280        format!("{}: {}", p, msg)
281    }
282}
283
284/// Append a suffix to a path, handling empty path case.
285#[cfg(debug_assertions)]
286fn append_path(path: &str, suffix: &str) -> String {
287    let p = format_path(path);
288    if p.is_empty() {
289        suffix.to_string()
290    } else {
291        format!("{}.{}", p, suffix)
292    }
293}
294
295/// Create a centered header line with dashes.
296#[cfg(debug_assertions)]
297fn centered_header(label: &str, width: usize) -> String {
298    let label_with_spaces = format!(" {} ", label);
299    let label_len = label_with_spaces.len();
300    if label_len >= width {
301        return label_with_spaces;
302    }
303    let remaining = width - label_len;
304    let left = remaining / 2;
305    let right = remaining - left;
306    format!(
307        "{}{}{}",
308        "-".repeat(left),
309        label_with_spaces,
310        "-".repeat(right)
311    )
312}
313
314/// Panic with a pretty diagnostic showing the type mismatch.
315#[cfg(debug_assertions)]
316fn panic_with_mismatch(
317    value: &Value,
318    declared_type: TypeId,
319    errors: &[String],
320    module: &Module,
321    colors: Colors,
322) -> ! {
323    const WIDTH: usize = 80;
324    let separator = "=".repeat(WIDTH);
325
326    let entrypoints = module.entrypoints();
327    let strings = module.strings();
328
329    // Find the entrypoint name by matching result_type
330    let type_name = (0..entrypoints.len())
331        .find_map(|i| {
332            let e = entrypoints.get(i);
333            if e.result_type() == declared_type {
334                Some(strings.get(e.name()))
335            } else {
336                None
337            }
338        })
339        .unwrap_or("unknown");
340
341    let value_str = value.format(true, colors);
342    let details_str = errors.join("\n");
343
344    let output_header = centered_header(&format!("Output: {}", type_name), WIDTH);
345    let details_header = centered_header("Details", WIDTH);
346
347    panic!(
348        "\n{separator}\n\
349         BUG: Type and value do not match\n\
350         {separator}\n\n\
351         {output_header}\n\n\
352         {value_str}\n\n\
353         {details_header}\n\n\
354         {details_str}\n\n\
355         {separator}\n"
356    );
357}