Skip to main content

harn_vm/
typecheck.rs

1//! Runtime type & arity validation, shared between user-defined function
2//! calls and registry-known builtin calls.
3//!
4//! Every call-site validation in the VM funnels through three entry points:
5//!
6//! - [`assert_value_matches_type`] — project a [`VmValue`] through the shared
7//!   `harn_kernel::type_contract` matcher. That matcher is the runtime source
8//!   of truth for native and portable `int`/`string`/`list<T>`/... value
9//!   compatibility and mirrors static `TypeChecker::types_compatible`
10//!   semantics on values rather than type expressions.
11//! - [`validate_user_call`] — arity check + per-arg declared-type
12//!   assertion for compiled user-defined functions
13//!   ([`crate::chunk::CompiledFunction`]).
14//! - [`validate_builtin_call`] — arity check + per-arg type assertion
15//!   for builtins, driven by the parser's
16//!   [`harn_parser::builtin_signatures`] registry. The runtime never
17//!   re-implements per-builtin validation; the registry is the contract.
18//!
19//! All three return [`crate::value::VmError`] variants
20//! ([`VmError::ArityMismatch`], [`VmError::ArgTypeMismatch`]) on failure
21//! so error UX is uniform. Callers may pass an optional
22//! [`harn_lexer::Span`] when they have a source location for the call
23//! site (e.g. derived from the chunk's PC→span table); when omitted the
24//! error renders without a positional suffix.
25
26use harn_kernel::type_contract::{RuntimeTypeKind, TypeContractValue};
27use harn_lexer::Span;
28use harn_parser::builtin_signatures::{self, BuiltinSignature, TyExt};
29use harn_parser::typechecker::format_type;
30use harn_parser::TypeExpr;
31
32use crate::chunk::{BindingTypeSlot, CompiledFunction, ParamSlot};
33use crate::runtime_guards::RuntimeParamGuard;
34use crate::value::{
35    ArgTypeMismatchError, ArityExpect, ArityMismatchError, BindingTypeMismatchError, VmError,
36    VmValue,
37};
38use crate::vm::CallArgs;
39
40impl TypeContractValue for VmValue {
41    fn runtime_type_kind(&self) -> RuntimeTypeKind {
42        match self {
43            Self::Int(_) => RuntimeTypeKind::Int,
44            Self::Float(_) => RuntimeTypeKind::Float,
45            Self::Decimal(_) => RuntimeTypeKind::Decimal,
46            Self::String(_) => RuntimeTypeKind::String,
47            Self::Bytes(_) => RuntimeTypeKind::Bytes,
48            Self::Bool(_) => RuntimeTypeKind::Bool,
49            Self::Nil => RuntimeTypeKind::Nil,
50            Self::List(_) => RuntimeTypeKind::List,
51            Self::Dict(_) => RuntimeTypeKind::Dict,
52            Self::Closure(_) | Self::BuiltinRef(_) | Self::BuiltinRefId(_) => {
53                RuntimeTypeKind::Closure
54            }
55            Self::Duration(_) => RuntimeTypeKind::Duration,
56            Self::EnumVariant(_) => RuntimeTypeKind::Enum,
57            Self::StructInstance(_) => RuntimeTypeKind::Struct,
58            Self::TaskHandle(_) => RuntimeTypeKind::TaskHandle,
59            Self::Channel(_) => RuntimeTypeKind::Channel,
60            Self::Atomic(_) => RuntimeTypeKind::Atomic,
61            Self::Rng(_) => RuntimeTypeKind::Rng,
62            Self::SyncPermit(_) => RuntimeTypeKind::SyncPermit,
63            Self::Resource(_) => RuntimeTypeKind::Resource,
64            Self::ResourceGuard(_) => RuntimeTypeKind::ResourceGuard,
65            Self::McpClient(_) => RuntimeTypeKind::McpClient,
66            Self::VerdictReceipt(_) => RuntimeTypeKind::VerdictReceipt,
67            Self::Set(_) => RuntimeTypeKind::Set,
68            Self::Generator(_) => RuntimeTypeKind::Generator,
69            Self::Stream(_) => RuntimeTypeKind::Stream,
70            Self::Range(_) => RuntimeTypeKind::Range,
71            Self::Iter(_) => RuntimeTypeKind::Iter,
72            Self::Pair(_) => RuntimeTypeKind::Pair,
73            Self::Harness(_) => RuntimeTypeKind::Harness,
74        }
75    }
76
77    fn list_items(&self) -> Option<&[Self]> {
78        match self {
79            Self::List(items) => Some(items),
80            _ => None,
81        }
82    }
83
84    fn record_field(&self, name: &str) -> Option<&Self> {
85        match self {
86            Self::Dict(fields) => fields.get(name),
87            Self::StructInstance(_) => self.struct_field(name),
88            _ => None,
89        }
90    }
91
92    fn record_values_match(&self, predicate: &mut dyn FnMut(&Self) -> bool) -> Option<bool> {
93        match self {
94            Self::Dict(fields) => Some(fields.values().all(predicate)),
95            _ => None,
96        }
97    }
98
99    fn string_literal(&self) -> Option<&str> {
100        match self {
101            Self::String(value) => Some(value.as_str()),
102            _ => None,
103        }
104    }
105
106    fn int_literal(&self) -> Option<i64> {
107        match self {
108            Self::Int(value) => Some(*value),
109            _ => None,
110        }
111    }
112
113    fn nominal_type_name(&self) -> Option<&str> {
114        match self {
115            Self::StructInstance(value) => Some(value.layout.struct_name()),
116            Self::EnumVariant(value) => Some(value.enum_name.as_str()),
117            _ => None,
118        }
119    }
120}
121
122/// Validate that `value` satisfies `expected`. Returns `Ok(())` when the
123/// value is acceptable, otherwise an [`VmError::ArgTypeMismatch`] tagged
124/// with `callee` / `param` / `span` for the caller's diagnostic.
125///
126/// The shared kernel matcher mirrors the static checker's `types_compatible`
127/// rules:
128/// - `Named("any")` and the special generic-parameter sentinel skip
129///   validation (any value passes).
130/// - `Named("number")` accepts `int` or `float`.
131/// - `Optional<T>` / `T | nil` accepts the inner type or `Nil`.
132/// - `list<T>`, `dict<K, V>`, `iter<T>`, `Generator<T>`, `Stream<T>`
133///   check the container; element-level validation is per element when
134///   the value is a literal `VmValue::List` / `VmValue::Dict` whose
135///   contents are cheap to walk. For lazy iterators / streams we skip
136///   element validation (they may be infinite or expensive).
137/// - `Shape{...}` validates field presence and per-field types against
138///   `VmValue::Dict` and `VmValue::StructInstance`.
139/// - `Union(...)` accepts any matching alternative.
140/// - `Intersection(...)` accepts only when *every* alternative matches.
141/// - Literal types (`LitInt`, `LitString`) require value equality with
142///   the literal.
143/// - `Never` always rejects.
144pub fn assert_value_matches_type(
145    value: &VmValue,
146    expected: &TypeExpr,
147    callee: &str,
148    param: &str,
149    span: Option<Span>,
150) -> Result<(), VmError> {
151    assert_value_matches_type_with_generics(value, expected, callee, param, span, &[], &[])
152}
153
154fn assert_value_matches_type_with_generics(
155    value: &VmValue,
156    expected: &TypeExpr,
157    callee: &str,
158    param: &str,
159    span: Option<Span>,
160    type_params: &[String],
161    nominal_type_names: &[String],
162) -> Result<(), VmError> {
163    if matches_type_with_generics(value, expected, type_params, nominal_type_names) {
164        Ok(())
165    } else {
166        Err(VmError::ArgTypeMismatch(Box::new(ArgTypeMismatchError {
167            callee: callee.to_string(),
168            param: param.to_string(),
169            expected: format_type(expected),
170            got: value.type_name(),
171            span,
172        })))
173    }
174}
175
176/// Validate an annotated `let` / `const` initializer against its declared type.
177///
178/// The binding-site counterpart of [`validate_user_call`]'s per-parameter
179/// assertion, and deliberately the same decision procedure: both project the
180/// value through `harn_kernel::type_contract::matches_type`. A declared type
181/// therefore accepts and rejects the same values whether it is written on a
182/// parameter or on a binding — which is the whole point of checking bindings
183/// at all (harn#6252).
184pub fn validate_binding_type(
185    value: &VmValue,
186    slot: &BindingTypeSlot,
187    span: Option<Span>,
188) -> Result<(), VmError> {
189    if matches_type_with_generics(value, &slot.type_expr, &[], &slot.nominal_type_names) {
190        return Ok(());
191    }
192    Err(VmError::BindingTypeMismatch(Box::new(
193        BindingTypeMismatchError {
194            binding: slot.name.clone(),
195            expected: format_type(&slot.type_expr),
196            got: value.type_name(),
197            span,
198        },
199    )))
200}
201
202fn user_param_for_arg(func: &CompiledFunction, index: usize) -> Option<&ParamSlot> {
203    if func.has_rest_param && index >= func.params.len().saturating_sub(1) {
204        func.params.last()
205    } else {
206        func.params.get(index)
207    }
208}
209
210fn builtin_param_for_arg(
211    sig: &BuiltinSignature,
212    index: usize,
213) -> Option<&harn_parser::builtin_signatures::Param> {
214    if sig.has_rest && index >= sig.params.len().saturating_sub(1) {
215        sig.params.last()
216    } else {
217        sig.params.get(index)
218    }
219}
220
221/// Recursive predicate driving [`assert_value_matches_type`]. Kept
222/// internal so the public API only exposes `Result`-returning forms.
223#[cfg(test)]
224fn matches_type(value: &VmValue, expected: &TypeExpr) -> bool {
225    matches_type_with_generics(value, expected, &[], &[])
226}
227
228fn matches_type_with_generics(
229    value: &VmValue,
230    expected: &TypeExpr,
231    type_params: &[String],
232    nominal_type_names: &[String],
233) -> bool {
234    harn_kernel::type_contract::matches_type(value, expected, type_params, nominal_type_names)
235}
236
237/// Validate a user-defined function call: arity (respecting defaults +
238/// rest), then per-parameter declared-type assertion for parameters
239/// that carry a [`TypeExpr`] in their [`crate::chunk::ParamSlot`].
240pub fn validate_user_call(
241    func: &CompiledFunction,
242    args: &[VmValue],
243    span: Option<Span>,
244) -> Result<(), VmError> {
245    validate_user_call_args(func, &CallArgs::Slice(args), span)
246}
247
248pub(crate) fn validate_user_call_args(
249    func: &CompiledFunction,
250    args: &CallArgs<'_>,
251    span: Option<Span>,
252) -> Result<(), VmError> {
253    let required = func.minimum_arg_count();
254    let got = args.len();
255
256    if got < required {
257        let expected = arity_expect_for(func);
258        return Err(VmError::ArityMismatch(Box::new(ArityMismatchError {
259            callee: func.name.to_string(),
260            expected,
261            got,
262            span,
263        })));
264    }
265
266    if !func.has_runtime_type_checks {
267        return Ok(());
268    }
269
270    for (i, value) in args.iter().enumerate() {
271        let Some(slot) = user_param_for_arg(func, i) else {
272            continue;
273        };
274        let Some(expected) = &slot.type_expr else {
275            continue;
276        };
277        if let Some(guard) = &slot.runtime_guard {
278            validate_with_runtime_guard(value, guard, func, slot, span)?;
279            continue;
280        }
281        validate_uncached_type_expr(value, expected, func, slot, span)?;
282    }
283
284    Ok(())
285}
286
287fn validate_with_runtime_guard(
288    value: &VmValue,
289    guard: &RuntimeParamGuard,
290    func: &CompiledFunction,
291    slot: &ParamSlot,
292    span: Option<Span>,
293) -> Result<(), VmError> {
294    match guard {
295        RuntimeParamGuard::CanonicalSchema(schema) => {
296            crate::schema::schema_assert_canonical_param(value, &slot.name, schema)
297                .map_err(|error| parameter_error_with_function(error, &func.name))
298        }
299        RuntimeParamGuard::InvalidSchema(error) => Err(VmError::TypeError(format!(
300            "function '{}' parameter '{}': {}",
301            func.name, slot.name, error
302        ))),
303        RuntimeParamGuard::TypeExpr(expected) => {
304            validate_type_expr_without_schema(value, expected, func, slot, span)
305        }
306    }
307}
308
309fn parameter_error_with_function(error: VmError, function_name: &str) -> VmError {
310    match error {
311        VmError::TypeError(message) => {
312            VmError::TypeError(format!("function '{function_name}' {message}"))
313        }
314        VmError::Runtime(message) => VmError::Runtime(message.replacen(
315            "TypeError: ",
316            &format!("TypeError: function '{function_name}' "),
317            1,
318        )),
319        other => other,
320    }
321}
322
323fn validate_uncached_type_expr(
324    value: &VmValue,
325    expected: &TypeExpr,
326    func: &CompiledFunction,
327    slot: &ParamSlot,
328    span: Option<Span>,
329) -> Result<(), VmError> {
330    if matches!(expected, TypeExpr::Named(name) if func.declares_type_param(name)) {
331        return Ok(());
332    }
333    if let Some(schema) = crate::compiler::Compiler::type_expr_to_schema_value(expected) {
334        crate::schema::schema_assert_param(value, &slot.name, &schema)?;
335        return Ok(());
336    }
337    validate_type_expr_without_schema(value, expected, func, slot, span)
338}
339
340fn validate_type_expr_without_schema(
341    value: &VmValue,
342    expected: &TypeExpr,
343    func: &CompiledFunction,
344    slot: &ParamSlot,
345    span: Option<Span>,
346) -> Result<(), VmError> {
347    assert_value_matches_type_with_generics(
348        value,
349        expected,
350        &func.name,
351        &slot.name,
352        span,
353        &func.type_params,
354        &func.nominal_type_names,
355    )
356}
357
358/// Validate a builtin call against the parser's signature registry.
359/// Returns `Ok(())` when the builtin is unknown to the registry — the
360/// alignment guarantee enforced at registration time means unknown
361/// names are necessarily internal/special-purpose builtins
362/// (e.g. compiler-synthesized `__*`) that don't need runtime
363/// validation.
364pub fn validate_builtin_call(
365    name: &str,
366    args: &[VmValue],
367    span: Option<Span>,
368) -> Result<(), VmError> {
369    let Some(sig) = builtin_signatures::lookup(name) else {
370        return Ok(());
371    };
372    validate_against_signature(name, sig, args, span)
373}
374
375/// Shared implementation for [`validate_builtin_call`] (and any future
376/// callers that already have a signature in hand). Public so test
377/// harnesses can drive it directly with synthetic signatures.
378pub fn validate_against_signature(
379    name: &str,
380    sig: &BuiltinSignature,
381    args: &[VmValue],
382    span: Option<Span>,
383) -> Result<(), VmError> {
384    let total = sig.params.len();
385    let required = sig.required_params();
386    let got = args.len();
387
388    let arity_ok = if sig.has_rest {
389        got >= total.saturating_sub(1)
390    } else {
391        got >= required && got <= total
392    };
393
394    if !arity_ok {
395        let expected = if sig.has_rest {
396            ArityExpect::AtLeast(total.saturating_sub(1))
397        } else if required == total {
398            ArityExpect::Exact(total)
399        } else {
400            ArityExpect::Range {
401                min: required,
402                max: total,
403            }
404        };
405        return Err(VmError::ArityMismatch(Box::new(ArityMismatchError {
406            callee: name.to_string(),
407            expected,
408            got,
409            span,
410        })));
411    }
412
413    for (i, value) in args.iter().enumerate() {
414        let Some(param) = builtin_param_for_arg(sig, i) else {
415            continue;
416        };
417        if param.optional && matches!(value, VmValue::Nil) {
418            continue;
419        }
420        // Generic type parameters inside builtin signatures are not
421        // resolvable at the value level — the static checker handles
422        // them. Skip type-param positions at runtime to avoid bogus
423        // mismatches.
424        let expected = param.ty.to_type_expr();
425        if matches!(&expected, TypeExpr::Named(n) if sig.is_type_param(n.as_str())) {
426            continue;
427        }
428        // `any` is always satisfied; format_type would render "any"
429        // and the runtime predicate accepts everything anyway.
430        if param.ty.is_any() {
431            continue;
432        }
433        if matches!(param.ty, harn_parser::builtin_signatures::Ty::SchemaOf(_)) {
434            continue;
435        }
436        assert_value_matches_type(value, &expected, name, param.name, span)?;
437    }
438
439    Ok(())
440}
441
442/// Compute the [`ArityExpect`] to embed in an [`VmError::ArityMismatch`]
443/// for a user-defined function. Respects defaults and rest-param flags
444/// so the message reads naturally.
445fn arity_expect_for(func: &CompiledFunction) -> ArityExpect {
446    ArityExpect::AtLeast(func.minimum_arg_count())
447}
448
449#[cfg(test)]
450mod tests {
451    use super::*;
452    use crate::chunk::Chunk;
453    use std::sync::Arc;
454
455    fn vm_int(n: i64) -> VmValue {
456        VmValue::Int(n)
457    }
458
459    fn vm_string(s: &str) -> VmValue {
460        VmValue::String(arcstr::ArcStr::from(s))
461    }
462
463    fn vm_dict(entries: impl IntoIterator<Item = (&'static str, VmValue)>) -> VmValue {
464        VmValue::dict(entries)
465    }
466
467    fn ty_int() -> TypeExpr {
468        TypeExpr::Named("int".into())
469    }
470
471    fn ty_string() -> TypeExpr {
472        TypeExpr::Named("string".into())
473    }
474
475    fn param_slot(name: &str, type_expr: Option<TypeExpr>) -> ParamSlot {
476        ParamSlot {
477            name: name.to_string(),
478            runtime_guard: type_expr.as_ref().map(RuntimeParamGuard::from_type_expr),
479            type_expr,
480            has_default: false,
481        }
482    }
483
484    fn compiled_function(params: Vec<ParamSlot>) -> CompiledFunction {
485        let has_runtime_type_checks = CompiledFunction::has_runtime_type_checks_for_params(&params);
486        CompiledFunction {
487            name: arcstr::literal!("f"),
488            type_params: Vec::new(),
489            nominal_type_names: Vec::new(),
490            params,
491            default_start: None,
492            chunk: Arc::new(Chunk::new()),
493            is_generator: false,
494            is_stream: false,
495            has_rest_param: false,
496            has_runtime_type_checks,
497        }
498    }
499
500    #[test]
501    fn matches_primitive_types() {
502        assert!(matches_type(&vm_int(42), &ty_int()));
503        assert!(!matches_type(&vm_int(42), &ty_string()));
504        assert!(matches_type(&vm_string("x"), &ty_string()));
505        assert!(matches_type(
506            &VmValue::Bool(true),
507            &TypeExpr::Named("bool".into())
508        ));
509        assert!(matches_type(&VmValue::Nil, &TypeExpr::Named("nil".into())));
510    }
511
512    #[test]
513    fn float_accepts_int_promotion() {
514        // Mirrors the static rule: `int` is assignable to `float`.
515        assert!(matches_type(&vm_int(3), &TypeExpr::Named("float".into())));
516        assert!(matches_type(
517            &VmValue::Float(3.0),
518            &TypeExpr::Named("float".into())
519        ));
520    }
521
522    #[test]
523    fn union_accepts_any_member() {
524        let union = TypeExpr::Union(vec![ty_int(), ty_string()]);
525        assert!(matches_type(&vm_int(1), &union));
526        assert!(matches_type(&vm_string("y"), &union));
527        assert!(!matches_type(&VmValue::Bool(true), &union));
528    }
529
530    #[test]
531    fn optional_accepts_nil() {
532        let opt = TypeExpr::Union(vec![ty_string(), TypeExpr::Named("nil".into())]);
533        assert!(matches_type(&VmValue::Nil, &opt));
534        assert!(matches_type(&vm_string("x"), &opt));
535        assert!(!matches_type(&vm_int(1), &opt));
536    }
537
538    #[test]
539    fn list_validates_elements() {
540        let list_int = TypeExpr::List(Box::new(ty_int()));
541        let good = VmValue::List(std::sync::Arc::new(vec![vm_int(1), vm_int(2)]));
542        let bad = VmValue::List(std::sync::Arc::new(vec![vm_int(1), vm_string("x")]));
543        assert!(matches_type(&good, &list_int));
544        assert!(!matches_type(&bad, &list_int));
545    }
546
547    #[test]
548    fn tuple_validates_arity_and_each_position() {
549        let tuple = TypeExpr::Tuple(vec![ty_int(), ty_string()]);
550        let good = VmValue::List(std::sync::Arc::new(vec![vm_int(1), vm_string("x")]));
551        let wrong_position = VmValue::List(std::sync::Arc::new(vec![vm_string("x"), vm_int(1)]));
552        let wrong_arity = VmValue::List(std::sync::Arc::new(vec![vm_int(1)]));
553        assert!(matches_type(&good, &tuple));
554        assert!(!matches_type(&wrong_position, &tuple));
555        assert!(!matches_type(&wrong_arity, &tuple));
556    }
557
558    #[test]
559    fn shape_validates_required_fields() {
560        let shape = TypeExpr::Shape(vec![harn_parser::ShapeField::synthetic(
561            "x",
562            ty_int(),
563            false,
564        )]);
565        let mut good = std::collections::BTreeMap::new();
566        good.insert("x".to_string(), vm_int(7));
567        assert!(matches_type(&VmValue::dict(good), &shape));
568        assert!(!matches_type(
569            &VmValue::dict_map(Default::default()),
570            &shape
571        ));
572    }
573
574    #[test]
575    fn named_type_matches_user_struct_name() {
576        let custom = TypeExpr::Named("MyStruct".into());
577        assert!(!matches_type_with_generics(
578            &vm_int(1),
579            &custom,
580            &[],
581            &["MyStruct".to_string()]
582        ));
583        assert!(matches_type_with_generics(
584            &VmValue::struct_instance("MyStruct", Default::default()),
585            &custom,
586            &[],
587            &["MyStruct".to_string()]
588        ));
589    }
590
591    #[test]
592    fn lit_int_requires_value_equality() {
593        assert!(matches_type(&vm_int(42), &TypeExpr::LitInt(42)));
594        assert!(!matches_type(&vm_int(7), &TypeExpr::LitInt(42)));
595    }
596
597    #[test]
598    fn assert_value_returns_arg_type_mismatch_on_fail() {
599        let err =
600            assert_value_matches_type(&vm_string("abc"), &ty_int(), "myFn", "n", None).unwrap_err();
601        match err {
602            VmError::ArgTypeMismatch(err) => {
603                assert_eq!(err.callee, "myFn");
604                assert_eq!(err.param, "n");
605                assert_eq!(err.expected, "int");
606                assert_eq!(err.got, "string");
607                assert!(err.span.is_none());
608            }
609            other => panic!("expected ArgTypeMismatch, got {other:?}"),
610        }
611    }
612
613    #[test]
614    fn validate_user_call_skips_param_walk_for_untyped_function() {
615        let func = compiled_function(vec![param_slot("value", None)]);
616
617        validate_user_call(&func, &[vm_string("anything")], None).unwrap();
618
619        let err = validate_user_call(&func, &[], None).unwrap_err();
620        assert!(matches!(err, VmError::ArityMismatch(_)));
621    }
622
623    #[test]
624    fn validate_user_call_checks_typed_function() {
625        let func = compiled_function(vec![param_slot("value", Some(ty_int()))]);
626
627        validate_user_call(&func, &[vm_int(1)], None).unwrap();
628
629        let err = validate_user_call(&func, &[vm_string("bad")], None).unwrap_err();
630        assert!(matches!(err, VmError::Runtime(_) | VmError::TypeError(_)));
631    }
632
633    #[test]
634    fn validate_user_call_uses_cached_runtime_guard_metadata() {
635        let string_schema = VmValue::dict(std::collections::BTreeMap::from([(
636            "type".to_string(),
637            VmValue::String(arcstr::ArcStr::from("string")),
638        )]));
639        let guard = RuntimeParamGuard::CanonicalSchema(
640            crate::schema::canonical_param_schema(&string_schema).unwrap(),
641        );
642        let func = compiled_function(vec![ParamSlot {
643            name: "value".to_string(),
644            type_expr: Some(ty_int()),
645            runtime_guard: Some(guard),
646            has_default: false,
647        }]);
648
649        validate_user_call(&func, &[vm_string("cached")], None).unwrap();
650        validate_user_call(&func, &[vm_string("guard")], None).unwrap();
651
652        let err = validate_user_call(&func, &[vm_int(1)], None).unwrap_err();
653        assert!(matches!(err, VmError::Runtime(_) | VmError::TypeError(_)));
654    }
655
656    #[test]
657    fn runtime_guard_does_not_narrow_partially_lowerable_union() {
658        let func = compiled_function(vec![param_slot(
659            "options",
660            Some(TypeExpr::Union(vec![
661                TypeExpr::Named("OpenOptions".into()),
662                TypeExpr::Named("nil".into()),
663            ])),
664        )]);
665
666        validate_user_call(&func, &[VmValue::Nil], None).unwrap();
667        validate_user_call(&func, &[vm_dict([("foo", vm_string("ok"))])], None).unwrap();
668    }
669}