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