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