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.clone(),
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        }
298        RuntimeParamGuard::InvalidSchema(error) => Err(VmError::TypeError(format!(
299            "parameter '{}': {}",
300            slot.name, error
301        ))),
302        RuntimeParamGuard::TypeExpr(expected) => {
303            validate_type_expr_without_schema(value, expected, func, slot, span)
304        }
305    }
306}
307
308fn validate_uncached_type_expr(
309    value: &VmValue,
310    expected: &TypeExpr,
311    func: &CompiledFunction,
312    slot: &ParamSlot,
313    span: Option<Span>,
314) -> Result<(), VmError> {
315    if matches!(expected, TypeExpr::Named(name) if func.declares_type_param(name)) {
316        return Ok(());
317    }
318    if let Some(schema) = crate::compiler::Compiler::type_expr_to_schema_value(expected) {
319        crate::schema::schema_assert_param(value, &slot.name, &schema)?;
320        return Ok(());
321    }
322    validate_type_expr_without_schema(value, expected, func, slot, span)
323}
324
325fn validate_type_expr_without_schema(
326    value: &VmValue,
327    expected: &TypeExpr,
328    func: &CompiledFunction,
329    slot: &ParamSlot,
330    span: Option<Span>,
331) -> Result<(), VmError> {
332    assert_value_matches_type_with_generics(
333        value,
334        expected,
335        &func.name,
336        &slot.name,
337        span,
338        &func.type_params,
339        &func.nominal_type_names,
340    )
341}
342
343/// Validate a builtin call against the parser's signature registry.
344/// Returns `Ok(())` when the builtin is unknown to the registry — the
345/// alignment guarantee enforced at registration time means unknown
346/// names are necessarily internal/special-purpose builtins
347/// (e.g. compiler-synthesized `__*`) that don't need runtime
348/// validation.
349pub fn validate_builtin_call(
350    name: &str,
351    args: &[VmValue],
352    span: Option<Span>,
353) -> Result<(), VmError> {
354    let Some(sig) = builtin_signatures::lookup(name) else {
355        return Ok(());
356    };
357    validate_against_signature(name, sig, args, span)
358}
359
360/// Shared implementation for [`validate_builtin_call`] (and any future
361/// callers that already have a signature in hand). Public so test
362/// harnesses can drive it directly with synthetic signatures.
363pub fn validate_against_signature(
364    name: &str,
365    sig: &BuiltinSignature,
366    args: &[VmValue],
367    span: Option<Span>,
368) -> Result<(), VmError> {
369    let total = sig.params.len();
370    let required = sig.required_params();
371    let got = args.len();
372
373    let arity_ok = if sig.has_rest {
374        got >= total.saturating_sub(1)
375    } else {
376        got >= required && got <= total
377    };
378
379    if !arity_ok {
380        let expected = if sig.has_rest {
381            ArityExpect::AtLeast(total.saturating_sub(1))
382        } else if required == total {
383            ArityExpect::Exact(total)
384        } else {
385            ArityExpect::Range {
386                min: required,
387                max: total,
388            }
389        };
390        return Err(VmError::ArityMismatch(Box::new(ArityMismatchError {
391            callee: name.to_string(),
392            expected,
393            got,
394            span,
395        })));
396    }
397
398    for (i, value) in args.iter().enumerate() {
399        let Some(param) = builtin_param_for_arg(sig, i) else {
400            continue;
401        };
402        if param.optional && matches!(value, VmValue::Nil) {
403            continue;
404        }
405        // Generic type parameters inside builtin signatures are not
406        // resolvable at the value level — the static checker handles
407        // them. Skip type-param positions at runtime to avoid bogus
408        // mismatches.
409        let expected = param.ty.to_type_expr();
410        if matches!(&expected, TypeExpr::Named(n) if sig.is_type_param(n.as_str())) {
411            continue;
412        }
413        // `any` is always satisfied; format_type would render "any"
414        // and the runtime predicate accepts everything anyway.
415        if param.ty.is_any() {
416            continue;
417        }
418        if matches!(param.ty, harn_parser::builtin_signatures::Ty::SchemaOf(_)) {
419            continue;
420        }
421        assert_value_matches_type(value, &expected, name, param.name, span)?;
422    }
423
424    Ok(())
425}
426
427/// Compute the [`ArityExpect`] to embed in an [`VmError::ArityMismatch`]
428/// for a user-defined function. Respects defaults and rest-param flags
429/// so the message reads naturally.
430fn arity_expect_for(func: &CompiledFunction) -> ArityExpect {
431    ArityExpect::AtLeast(func.minimum_arg_count())
432}
433
434#[cfg(test)]
435mod tests {
436    use super::*;
437    use crate::chunk::Chunk;
438    use std::sync::Arc;
439
440    fn vm_int(n: i64) -> VmValue {
441        VmValue::Int(n)
442    }
443
444    fn vm_string(s: &str) -> VmValue {
445        VmValue::String(arcstr::ArcStr::from(s))
446    }
447
448    fn vm_dict(entries: impl IntoIterator<Item = (&'static str, VmValue)>) -> VmValue {
449        VmValue::dict(entries)
450    }
451
452    fn ty_int() -> TypeExpr {
453        TypeExpr::Named("int".into())
454    }
455
456    fn ty_string() -> TypeExpr {
457        TypeExpr::Named("string".into())
458    }
459
460    fn param_slot(name: &str, type_expr: Option<TypeExpr>) -> ParamSlot {
461        ParamSlot {
462            name: name.to_string(),
463            runtime_guard: type_expr.as_ref().map(RuntimeParamGuard::from_type_expr),
464            type_expr,
465            has_default: false,
466        }
467    }
468
469    fn compiled_function(params: Vec<ParamSlot>) -> CompiledFunction {
470        let has_runtime_type_checks = CompiledFunction::has_runtime_type_checks_for_params(&params);
471        CompiledFunction {
472            name: "f".to_string(),
473            type_params: Vec::new(),
474            nominal_type_names: Vec::new(),
475            params,
476            default_start: None,
477            chunk: Arc::new(Chunk::new()),
478            is_generator: false,
479            is_stream: false,
480            has_rest_param: false,
481            has_runtime_type_checks,
482        }
483    }
484
485    #[test]
486    fn matches_primitive_types() {
487        assert!(matches_type(&vm_int(42), &ty_int()));
488        assert!(!matches_type(&vm_int(42), &ty_string()));
489        assert!(matches_type(&vm_string("x"), &ty_string()));
490        assert!(matches_type(
491            &VmValue::Bool(true),
492            &TypeExpr::Named("bool".into())
493        ));
494        assert!(matches_type(&VmValue::Nil, &TypeExpr::Named("nil".into())));
495    }
496
497    #[test]
498    fn float_accepts_int_promotion() {
499        // Mirrors the static rule: `int` is assignable to `float`.
500        assert!(matches_type(&vm_int(3), &TypeExpr::Named("float".into())));
501        assert!(matches_type(
502            &VmValue::Float(3.0),
503            &TypeExpr::Named("float".into())
504        ));
505    }
506
507    #[test]
508    fn union_accepts_any_member() {
509        let union = TypeExpr::Union(vec![ty_int(), ty_string()]);
510        assert!(matches_type(&vm_int(1), &union));
511        assert!(matches_type(&vm_string("y"), &union));
512        assert!(!matches_type(&VmValue::Bool(true), &union));
513    }
514
515    #[test]
516    fn optional_accepts_nil() {
517        let opt = TypeExpr::Union(vec![ty_string(), TypeExpr::Named("nil".into())]);
518        assert!(matches_type(&VmValue::Nil, &opt));
519        assert!(matches_type(&vm_string("x"), &opt));
520        assert!(!matches_type(&vm_int(1), &opt));
521    }
522
523    #[test]
524    fn list_validates_elements() {
525        let list_int = TypeExpr::List(Box::new(ty_int()));
526        let good = VmValue::List(std::sync::Arc::new(vec![vm_int(1), vm_int(2)]));
527        let bad = VmValue::List(std::sync::Arc::new(vec![vm_int(1), vm_string("x")]));
528        assert!(matches_type(&good, &list_int));
529        assert!(!matches_type(&bad, &list_int));
530    }
531
532    #[test]
533    fn tuple_validates_arity_and_each_position() {
534        let tuple = TypeExpr::Tuple(vec![ty_int(), ty_string()]);
535        let good = VmValue::List(std::sync::Arc::new(vec![vm_int(1), vm_string("x")]));
536        let wrong_position = VmValue::List(std::sync::Arc::new(vec![vm_string("x"), vm_int(1)]));
537        let wrong_arity = VmValue::List(std::sync::Arc::new(vec![vm_int(1)]));
538        assert!(matches_type(&good, &tuple));
539        assert!(!matches_type(&wrong_position, &tuple));
540        assert!(!matches_type(&wrong_arity, &tuple));
541    }
542
543    #[test]
544    fn shape_validates_required_fields() {
545        let shape = TypeExpr::Shape(vec![harn_parser::ShapeField::synthetic(
546            "x",
547            ty_int(),
548            false,
549        )]);
550        let mut good = std::collections::BTreeMap::new();
551        good.insert("x".to_string(), vm_int(7));
552        assert!(matches_type(&VmValue::dict(good), &shape));
553        assert!(!matches_type(
554            &VmValue::dict_map(Default::default()),
555            &shape
556        ));
557    }
558
559    #[test]
560    fn named_type_matches_user_struct_name() {
561        let custom = TypeExpr::Named("MyStruct".into());
562        assert!(!matches_type_with_generics(
563            &vm_int(1),
564            &custom,
565            &[],
566            &["MyStruct".to_string()]
567        ));
568        assert!(matches_type_with_generics(
569            &VmValue::struct_instance("MyStruct", Default::default()),
570            &custom,
571            &[],
572            &["MyStruct".to_string()]
573        ));
574    }
575
576    #[test]
577    fn lit_int_requires_value_equality() {
578        assert!(matches_type(&vm_int(42), &TypeExpr::LitInt(42)));
579        assert!(!matches_type(&vm_int(7), &TypeExpr::LitInt(42)));
580    }
581
582    #[test]
583    fn assert_value_returns_arg_type_mismatch_on_fail() {
584        let err =
585            assert_value_matches_type(&vm_string("abc"), &ty_int(), "myFn", "n", None).unwrap_err();
586        match err {
587            VmError::ArgTypeMismatch(err) => {
588                assert_eq!(err.callee, "myFn");
589                assert_eq!(err.param, "n");
590                assert_eq!(err.expected, "int");
591                assert_eq!(err.got, "string");
592                assert!(err.span.is_none());
593            }
594            other => panic!("expected ArgTypeMismatch, got {other:?}"),
595        }
596    }
597
598    #[test]
599    fn validate_user_call_skips_param_walk_for_untyped_function() {
600        let func = compiled_function(vec![param_slot("value", None)]);
601
602        validate_user_call(&func, &[vm_string("anything")], None).unwrap();
603
604        let err = validate_user_call(&func, &[], None).unwrap_err();
605        assert!(matches!(err, VmError::ArityMismatch(_)));
606    }
607
608    #[test]
609    fn validate_user_call_checks_typed_function() {
610        let func = compiled_function(vec![param_slot("value", Some(ty_int()))]);
611
612        validate_user_call(&func, &[vm_int(1)], None).unwrap();
613
614        let err = validate_user_call(&func, &[vm_string("bad")], None).unwrap_err();
615        assert!(matches!(err, VmError::Runtime(_) | VmError::TypeError(_)));
616    }
617
618    #[test]
619    fn validate_user_call_uses_cached_runtime_guard_metadata() {
620        let string_schema = VmValue::dict(std::collections::BTreeMap::from([(
621            "type".to_string(),
622            VmValue::String(arcstr::ArcStr::from("string")),
623        )]));
624        let guard = RuntimeParamGuard::CanonicalSchema(
625            crate::schema::canonical_param_schema(&string_schema).unwrap(),
626        );
627        let func = compiled_function(vec![ParamSlot {
628            name: "value".to_string(),
629            type_expr: Some(ty_int()),
630            runtime_guard: Some(guard),
631            has_default: false,
632        }]);
633
634        validate_user_call(&func, &[vm_string("cached")], None).unwrap();
635        validate_user_call(&func, &[vm_string("guard")], None).unwrap();
636
637        let err = validate_user_call(&func, &[vm_int(1)], None).unwrap_err();
638        assert!(matches!(err, VmError::Runtime(_) | VmError::TypeError(_)));
639    }
640
641    #[test]
642    fn runtime_guard_does_not_narrow_partially_lowerable_union() {
643        let func = compiled_function(vec![param_slot(
644            "options",
645            Some(TypeExpr::Union(vec![
646                TypeExpr::Named("OpenOptions".into()),
647                TypeExpr::Named("nil".into()),
648            ])),
649        )]);
650
651        validate_user_call(&func, &[VmValue::Nil], None).unwrap();
652        validate_user_call(&func, &[vm_dict([("foo", vm_string("ok"))])], None).unwrap();
653    }
654}