1use std::collections::HashSet;
2use std::rc::Rc;
3
4use crate::ast::*;
5use crate::builtin_signatures;
6use crate::diagnostic_codes::{Code, Repair};
7use harn_lexer::{FixEdit, Span};
8
9type TypeMismatchEvidence = (Option<(Span, String)>, Option<Span>);
10
11mod binary_ops;
12mod exits;
13mod format;
14mod inference;
15mod schema_inference;
16mod scope;
17mod union;
18
19pub use exits::{block_definitely_exits, stmt_definitely_exits};
20pub use format::{format_type, shape_mismatch_detail};
21
22use schema_inference::schema_type_expr_from_node;
23use scope::TypeScope;
24
25#[derive(Debug, Clone)]
27pub struct InlayHintInfo {
28 pub line: usize,
30 pub column: usize,
31 pub label: String,
33}
34
35#[derive(Debug, Clone)]
37pub struct TypeDiagnostic {
38 pub code: Code,
39 pub message: String,
40 pub severity: DiagnosticSeverity,
41 pub span: Option<Span>,
42 pub help: Option<String>,
43 pub related: Vec<RelatedDiagnostic>,
44 pub fix: Option<Vec<FixEdit>>,
46 pub details: Option<DiagnosticDetails>,
51 pub repair: Option<Repair>,
57}
58
59#[derive(Debug, Clone)]
60pub struct RelatedDiagnostic {
61 pub span: Span,
62 pub message: String,
63}
64
65#[derive(Debug, Clone)]
72pub enum DiagnosticDetails {
73 TypeMismatch,
76 NonExhaustiveMatch { missing: Vec<String> },
83 LintRule { rule: &'static str },
88}
89
90#[derive(Debug, Clone, Copy, PartialEq, Eq)]
91pub enum DiagnosticSeverity {
92 Error,
93 Warning,
94}
95
96pub struct TypeChecker {
98 diagnostics: Vec<TypeDiagnostic>,
99 scope: Rc<TypeScope>,
105 source: Option<String>,
106 hints: Vec<InlayHintInfo>,
107 strict_types: bool,
109 fn_depth: usize,
112 stream_fn_depth: usize,
114 stream_emit_types: Vec<Option<TypeExpr>>,
116 deprecated_fns: std::collections::HashMap<String, (Option<String>, Option<String>)>,
121 imported_names: Option<HashSet<String>>,
128 imported_type_decls: Vec<SNode>,
132 imported_callable_decls: Vec<SNode>,
135 const_env: crate::const_eval::ConstEnv,
139}
140
141impl TypeChecker {
142 pub(in crate::typechecker) fn wildcard_type() -> TypeExpr {
143 TypeExpr::Named("_".into())
144 }
145
146 pub(in crate::typechecker) fn is_wildcard_type(ty: &TypeExpr) -> bool {
147 matches!(ty, TypeExpr::Named(name) if name == "_")
148 }
149
150 pub(in crate::typechecker) fn base_type_name(ty: &TypeExpr) -> Option<&str> {
151 match ty {
152 TypeExpr::Named(name) => Some(name.as_str()),
153 TypeExpr::Applied { name, .. } => Some(name.as_str()),
154 _ => None,
155 }
156 }
157
158 pub fn new() -> Self {
159 Self {
160 diagnostics: Vec::new(),
161 scope: Rc::new(TypeScope::new()),
162 source: None,
163 hints: Vec::new(),
164 strict_types: false,
165 fn_depth: 0,
166 stream_fn_depth: 0,
167 stream_emit_types: Vec::new(),
168 deprecated_fns: std::collections::HashMap::new(),
169 imported_names: None,
170 imported_type_decls: Vec::new(),
171 imported_callable_decls: Vec::new(),
172 const_env: crate::const_eval::ConstEnv::new(),
173 }
174 }
175
176 pub fn with_strict_types(strict: bool) -> Self {
179 Self {
180 diagnostics: Vec::new(),
181 scope: Rc::new(TypeScope::new()),
182 source: None,
183 hints: Vec::new(),
184 strict_types: strict,
185 fn_depth: 0,
186 stream_fn_depth: 0,
187 stream_emit_types: Vec::new(),
188 deprecated_fns: std::collections::HashMap::new(),
189 imported_names: None,
190 imported_type_decls: Vec::new(),
191 imported_callable_decls: Vec::new(),
192 const_env: crate::const_eval::ConstEnv::new(),
193 }
194 }
195
196 pub fn with_imported_names(mut self, imported: HashSet<String>) -> Self {
207 self.imported_names = Some(imported);
208 self
209 }
210
211 pub fn with_imported_type_decls(mut self, imported: Vec<SNode>) -> Self {
215 self.imported_type_decls = imported;
216 self
217 }
218
219 pub fn with_imported_callable_decls(mut self, imported: Vec<SNode>) -> Self {
224 self.imported_callable_decls = imported;
225 self
226 }
227
228 pub fn check_with_source(mut self, program: &[SNode], source: &str) -> Vec<TypeDiagnostic> {
230 self.source = Some(source.to_string());
231 self.check_inner(program).0
232 }
233
234 pub fn check_strict_with_source(
236 mut self,
237 program: &[SNode],
238 source: &str,
239 ) -> Vec<TypeDiagnostic> {
240 self.source = Some(source.to_string());
241 self.check_inner(program).0
242 }
243
244 pub fn check(self, program: &[SNode]) -> Vec<TypeDiagnostic> {
246 self.check_inner(program).0
247 }
248
249 pub(in crate::typechecker) fn detect_boundary_source(
253 value: &SNode,
254 scope: &TypeScope,
255 ) -> Option<String> {
256 match &value.node {
257 Node::FunctionCall { name, args, .. } => {
258 if !builtin_signatures::is_untyped_boundary_source(name) {
259 return None;
260 }
261 if (name == "llm_call" || name == "llm_completion")
263 && Self::llm_call_has_typed_schema_option(args, scope)
264 {
265 return None;
266 }
267 Some(name.clone())
268 }
269 Node::Identifier(name) => scope.is_untyped_source(name).map(|s| s.to_string()),
270 _ => None,
271 }
272 }
273
274 pub(in crate::typechecker) fn llm_call_has_typed_schema_option(
280 args: &[SNode],
281 scope: &TypeScope,
282 ) -> bool {
283 let Some(opts) = args.get(2) else {
284 return false;
285 };
286 let Node::DictLiteral(entries) = &opts.node else {
287 return false;
288 };
289 entries.iter().any(|entry| {
290 let key = match &entry.key.node {
291 Node::StringLiteral(k) | Node::Identifier(k) => k.as_str(),
292 _ => return false,
293 };
294 (key == "schema" || key == "output_schema")
295 && schema_type_expr_from_node(&entry.value, scope).is_some()
296 })
297 }
298
299 pub(in crate::typechecker) fn is_concrete_type(ty: &TypeExpr) -> bool {
302 matches!(
303 ty,
304 TypeExpr::Shape(_)
305 | TypeExpr::Applied { .. }
306 | TypeExpr::FnType { .. }
307 | TypeExpr::List(_)
308 | TypeExpr::Iter(_)
309 | TypeExpr::Generator(_)
310 | TypeExpr::Stream(_)
311 | TypeExpr::DictType(_, _)
312 ) || matches!(ty, TypeExpr::Named(n) if n != "dict" && n != "any" && n != "_")
313 }
314
315 pub fn check_with_hints(
317 mut self,
318 program: &[SNode],
319 source: &str,
320 ) -> (Vec<TypeDiagnostic>, Vec<InlayHintInfo>) {
321 self.source = Some(source.to_string());
322 self.check_inner(program)
323 }
324
325 pub(in crate::typechecker) fn error_at(&mut self, code: Code, message: String, span: Span) {
326 self.diagnostics.push(TypeDiagnostic {
327 code,
328 message,
329 severity: DiagnosticSeverity::Error,
330 span: Some(span),
331 help: None,
332 related: Vec::new(),
333 fix: None,
334 details: None,
335 repair: default_repair(code),
336 });
337 }
338
339 #[allow(dead_code)]
340 pub(in crate::typechecker) fn error_at_with_help(
341 &mut self,
342 code: Code,
343 message: String,
344 span: Span,
345 help: String,
346 ) {
347 self.diagnostics.push(TypeDiagnostic {
348 code,
349 message,
350 severity: DiagnosticSeverity::Error,
351 span: Some(span),
352 help: Some(help),
353 related: Vec::new(),
354 fix: None,
355 details: None,
356 repair: default_repair(code),
357 });
358 }
359
360 pub(in crate::typechecker) fn type_mismatch_at(
361 &mut self,
362 code: Code,
363 context: impl Into<String>,
364 expected: &TypeExpr,
365 actual: &TypeExpr,
366 span: Span,
367 evidence: TypeMismatchEvidence,
368 scope: &TypeScope,
369 ) {
370 let (expected_origin, value_span) = evidence;
371 let nested_mismatch = first_nested_mismatch(expected, actual, scope);
372 let mut message = format!(
373 "{}: expected {}, found {}",
374 context.into(),
375 format_type(expected),
376 format_type(actual)
377 );
378 if let Some(detail) = shape_mismatch_detail(expected, actual)
379 .or_else(|| nested_mismatch.as_ref().map(|note| note.message.clone()))
380 {
381 message.push_str(&format!(" ({detail})"));
382 }
383
384 let mut related = Vec::new();
385 if let Some((span, message)) = expected_origin {
386 related.push(RelatedDiagnostic { span, message });
387 }
388 if let Some(note) = nested_mismatch {
389 related.push(RelatedDiagnostic {
390 span,
391 message: format!("nested mismatch: {}", note.message),
392 });
393 }
394
395 self.diagnostics.push(TypeDiagnostic {
396 code,
397 message,
398 severity: DiagnosticSeverity::Error,
399 span: Some(span),
400 help: coercion_suggestion(expected, actual, value_span, self.source.as_deref()),
401 related,
402 fix: None,
403 details: Some(DiagnosticDetails::TypeMismatch),
404 repair: default_repair(code),
405 });
406 }
407
408 pub(in crate::typechecker) fn error_at_with_fix(
409 &mut self,
410 code: Code,
411 message: String,
412 span: Span,
413 fix: Vec<FixEdit>,
414 ) {
415 self.diagnostics.push(TypeDiagnostic {
416 code,
417 message,
418 severity: DiagnosticSeverity::Error,
419 span: Some(span),
420 help: None,
421 related: Vec::new(),
422 fix: Some(fix),
423 details: None,
424 repair: default_repair(code),
425 });
426 }
427
428 pub(in crate::typechecker) fn exhaustiveness_error_at(
435 &mut self,
436 code: Code,
437 message: String,
438 span: Span,
439 ) {
440 self.diagnostics.push(TypeDiagnostic {
441 code,
442 message,
443 severity: DiagnosticSeverity::Error,
444 span: Some(span),
445 help: None,
446 related: Vec::new(),
447 fix: None,
448 details: None,
449 repair: default_repair(code),
450 });
451 }
452
453 pub(in crate::typechecker) fn exhaustiveness_error_with_missing(
458 &mut self,
459 code: Code,
460 message: String,
461 span: Span,
462 missing: Vec<String>,
463 ) {
464 self.diagnostics.push(TypeDiagnostic {
465 code,
466 message,
467 severity: DiagnosticSeverity::Error,
468 span: Some(span),
469 help: None,
470 related: Vec::new(),
471 fix: None,
472 details: Some(DiagnosticDetails::NonExhaustiveMatch { missing }),
473 repair: default_repair(code),
474 });
475 }
476
477 pub(in crate::typechecker) fn warning_at(&mut self, code: Code, message: String, span: Span) {
478 self.diagnostics.push(TypeDiagnostic {
479 code,
480 message,
481 severity: DiagnosticSeverity::Warning,
482 span: Some(span),
483 help: None,
484 related: Vec::new(),
485 fix: None,
486 details: None,
487 repair: default_repair(code),
488 });
489 }
490
491 #[allow(dead_code)]
492 pub(in crate::typechecker) fn warning_at_with_help(
493 &mut self,
494 code: Code,
495 message: String,
496 span: Span,
497 help: String,
498 ) {
499 self.diagnostics.push(TypeDiagnostic {
500 code,
501 message,
502 severity: DiagnosticSeverity::Warning,
503 span: Some(span),
504 help: Some(help),
505 related: Vec::new(),
506 fix: None,
507 details: None,
508 repair: default_repair(code),
509 });
510 }
511
512 pub(in crate::typechecker) fn lint_warning_at_with_fix(
513 &mut self,
514 code: Code,
515 rule: &'static str,
516 message: String,
517 span: Span,
518 help: String,
519 fix: Vec<FixEdit>,
520 ) {
521 self.diagnostics.push(TypeDiagnostic {
522 code,
523 message,
524 severity: DiagnosticSeverity::Warning,
525 span: Some(span),
526 help: Some(help),
527 related: Vec::new(),
528 fix: Some(fix),
529 details: Some(DiagnosticDetails::LintRule { rule }),
530 repair: default_repair(code),
531 });
532 }
533}
534
535pub(crate) fn default_repair(code: Code) -> Option<Repair> {
540 code.repair_template().map(Repair::from_template)
541}
542
543#[derive(Debug)]
544struct MismatchNote {
545 message: String,
546}
547
548fn first_nested_mismatch(
549 expected: &TypeExpr,
550 actual: &TypeExpr,
551 scope: &TypeScope,
552) -> Option<MismatchNote> {
553 let expected = resolve_type_for_diagnostic(expected, scope);
554 let actual = resolve_type_for_diagnostic(actual, scope);
555 match (&expected, &actual) {
556 (TypeExpr::Shape(expected_fields), TypeExpr::Shape(actual_fields)) => {
557 for expected_field in expected_fields {
558 if expected_field.optional {
559 continue;
560 }
561 let Some(actual_field) = actual_fields
562 .iter()
563 .find(|actual_field| actual_field.name == expected_field.name)
564 else {
565 return Some(MismatchNote {
566 message: format!(
567 "field `{}` is missing; expected {}",
568 expected_field.name,
569 format_type(&expected_field.type_expr)
570 ),
571 });
572 };
573 if !types_compatible_for_diagnostic(
574 &expected_field.type_expr,
575 &actual_field.type_expr,
576 scope,
577 ) {
578 return Some(MismatchNote {
579 message: format!(
580 "field `{}` expected {}, found {}",
581 expected_field.name,
582 format_type(&expected_field.type_expr),
583 format_type(&actual_field.type_expr)
584 ),
585 });
586 }
587 }
588 None
589 }
590 (TypeExpr::List(expected_inner), TypeExpr::List(actual_inner)) => {
591 if !types_compatible_for_diagnostic(expected_inner, actual_inner, scope)
592 || !types_compatible_for_diagnostic(actual_inner, expected_inner, scope)
593 {
594 Some(MismatchNote {
595 message: format!(
596 "list element expected {}, found {}",
597 format_type(expected_inner),
598 format_type(actual_inner)
599 ),
600 })
601 } else {
602 None
603 }
604 }
605 (
606 TypeExpr::DictType(expected_key, expected_value),
607 TypeExpr::DictType(actual_key, actual_value),
608 ) => {
609 if !types_compatible_for_diagnostic(expected_key, actual_key, scope)
610 || !types_compatible_for_diagnostic(actual_key, expected_key, scope)
611 {
612 Some(MismatchNote {
613 message: format!(
614 "dict key expected {}, found {}",
615 format_type(expected_key),
616 format_type(actual_key)
617 ),
618 })
619 } else if !types_compatible_for_diagnostic(expected_value, actual_value, scope)
620 || !types_compatible_for_diagnostic(actual_value, expected_value, scope)
621 {
622 Some(MismatchNote {
623 message: format!(
624 "dict value expected {}, found {}",
625 format_type(expected_value),
626 format_type(actual_value)
627 ),
628 })
629 } else {
630 None
631 }
632 }
633 (
634 TypeExpr::Applied {
635 name: expected_name,
636 args: expected_args,
637 },
638 TypeExpr::Applied {
639 name: actual_name,
640 args: actual_args,
641 },
642 ) if expected_name == actual_name => expected_args
643 .iter()
644 .zip(actual_args.iter())
645 .enumerate()
646 .find_map(|(idx, (expected_arg, actual_arg))| {
647 if types_compatible_for_diagnostic(expected_arg, actual_arg, scope)
648 && types_compatible_for_diagnostic(actual_arg, expected_arg, scope)
649 {
650 None
651 } else {
652 Some(MismatchNote {
653 message: format!(
654 "{} type argument {} expected {}, found {}",
655 expected_name,
656 idx + 1,
657 format_type(expected_arg),
658 format_type(actual_arg)
659 ),
660 })
661 }
662 }),
663 (
664 TypeExpr::FnType {
665 params: expected_params,
666 return_type: expected_return,
667 },
668 TypeExpr::FnType {
669 params: actual_params,
670 return_type: actual_return,
671 },
672 ) => {
673 for (idx, (expected_param, actual_param)) in
674 expected_params.iter().zip(actual_params.iter()).enumerate()
675 {
676 if !types_compatible_for_diagnostic(actual_param, expected_param, scope) {
677 return Some(MismatchNote {
678 message: format!(
679 "function parameter {} expected {}, found {}",
680 idx + 1,
681 format_type(expected_param),
682 format_type(actual_param)
683 ),
684 });
685 }
686 }
687 if !types_compatible_for_diagnostic(expected_return, actual_return, scope) {
688 Some(MismatchNote {
689 message: format!(
690 "function return expected {}, found {}",
691 format_type(expected_return),
692 format_type(actual_return)
693 ),
694 })
695 } else {
696 None
697 }
698 }
699 _ => None,
700 }
701}
702
703fn types_compatible_for_diagnostic(
704 expected: &TypeExpr,
705 actual: &TypeExpr,
706 scope: &TypeScope,
707) -> bool {
708 TypeChecker::new().types_compatible(expected, actual, scope)
709}
710
711fn resolve_type_for_diagnostic(ty: &TypeExpr, scope: &TypeScope) -> TypeExpr {
712 TypeChecker::new().resolve_alias(ty, scope)
713}
714
715fn coercion_suggestion(
716 expected: &TypeExpr,
717 actual: &TypeExpr,
718 value_span: Option<Span>,
719 source: Option<&str>,
720) -> Option<String> {
721 let expr = value_span
722 .and_then(|span| source.and_then(|source| source.get(span.start..span.end)))
723 .map(str::trim)
724 .filter(|expr| !expr.is_empty());
725 if is_nilable(actual) {
726 return Some("handle `nil` first or provide a default with `??`".to_string());
727 }
728 let expected_ty = expected;
729 let expected = simple_type_name(expected)?;
730 let actual_name = simple_type_name(actual)?;
731 let with_expr = |template: &str| {
732 expr.map(|expr| template.replace("{}", expr))
733 .unwrap_or_else(|| template.replace("{}", "value"))
734 };
735
736 match (expected, actual_name) {
737 ("string", "int" | "float" | "bool" | "nil" | "duration") => {
738 Some(format!("did you mean `{}`?", with_expr("to_string({})")))
739 }
740 ("int", "string") => Some(format!("did you mean `{}`?", with_expr("to_int({})"))),
741 ("float", "string" | "int") => {
742 Some(format!("did you mean `{}`?", with_expr("to_float({})")))
743 }
744 (_, "nil") => Some("handle `nil` first or provide a default with `??`".to_string()),
745 _ if actual_is_result_of(expected_ty, actual) => Some(format!(
746 "did you mean `{}` or `{}`?",
747 with_expr("{}?"),
748 with_expr("unwrap_or({}, default)")
749 )),
750 _ => None,
751 }
752}
753
754fn simple_type_name(ty: &TypeExpr) -> Option<&str> {
755 match ty {
756 TypeExpr::Named(name) => Some(name.as_str()),
757 TypeExpr::LitString(_) => Some("string"),
758 TypeExpr::LitInt(_) => Some("int"),
759 _ => None,
760 }
761}
762
763fn is_nilable(ty: &TypeExpr) -> bool {
764 match ty {
765 TypeExpr::Union(members) if members.len() == 2 => members
766 .iter()
767 .any(|member| matches!(member, TypeExpr::Named(name) if name == "nil")),
768 _ => false,
769 }
770}
771
772fn actual_is_result_of(expected: &TypeExpr, actual: &TypeExpr) -> bool {
773 matches!(
774 actual,
775 TypeExpr::Applied { name, args }
776 if name == "Result" && args.first().is_some_and(|ok| ok == expected)
777 )
778}
779
780impl Default for TypeChecker {
781 fn default() -> Self {
782 Self::new()
783 }
784}
785
786#[cfg(test)]
787mod tests;