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.strict_types = true;
242 self.check_inner(program).0
243 }
244
245 pub fn check(self, program: &[SNode]) -> Vec<TypeDiagnostic> {
247 self.check_inner(program).0
248 }
249
250 pub(in crate::typechecker) fn detect_boundary_source(
254 value: &SNode,
255 scope: &TypeScope,
256 ) -> Option<String> {
257 match &value.node {
258 Node::FunctionCall { name, args, .. } => {
259 if !builtin_signatures::is_untyped_boundary_source(name) {
260 return None;
261 }
262 if (name == "llm_call" || name == "llm_completion")
264 && Self::llm_call_has_typed_schema_option(args, scope)
265 {
266 return None;
267 }
268 Some(name.clone())
269 }
270 Node::Identifier(name) => scope.is_untyped_source(name).map(|s| s.to_string()),
271 _ => None,
272 }
273 }
274
275 pub(in crate::typechecker) fn llm_call_has_typed_schema_option(
281 args: &[SNode],
282 scope: &TypeScope,
283 ) -> bool {
284 let Some(opts) = args.get(2) else {
285 return false;
286 };
287 let Node::DictLiteral(entries) = &opts.node else {
288 return false;
289 };
290 entries.iter().any(|entry| {
291 let key = match &entry.key.node {
292 Node::StringLiteral(k) | Node::Identifier(k) => k.as_str(),
293 _ => return false,
294 };
295 (key == "schema" || key == "output_schema")
296 && schema_type_expr_from_node(&entry.value, scope).is_some()
297 })
298 }
299
300 pub(in crate::typechecker) fn is_concrete_type(ty: &TypeExpr) -> bool {
303 matches!(
304 ty,
305 TypeExpr::Shape(_)
306 | TypeExpr::Applied { .. }
307 | TypeExpr::FnType { .. }
308 | TypeExpr::List(_)
309 | TypeExpr::Iter(_)
310 | TypeExpr::Generator(_)
311 | TypeExpr::Stream(_)
312 | TypeExpr::DictType(_, _)
313 ) || matches!(ty, TypeExpr::Named(n) if n != "dict" && n != "any" && n != "_")
314 }
315
316 pub fn check_with_hints(
318 mut self,
319 program: &[SNode],
320 source: &str,
321 ) -> (Vec<TypeDiagnostic>, Vec<InlayHintInfo>) {
322 self.source = Some(source.to_string());
323 self.check_inner(program)
324 }
325
326 pub(in crate::typechecker) fn error_at(&mut self, code: Code, message: String, span: Span) {
327 self.diagnostics.push(TypeDiagnostic {
328 code,
329 message,
330 severity: DiagnosticSeverity::Error,
331 span: Some(span),
332 help: None,
333 related: Vec::new(),
334 fix: None,
335 details: None,
336 repair: default_repair(code),
337 });
338 }
339
340 #[allow(dead_code)]
341 pub(in crate::typechecker) fn error_at_with_help(
342 &mut self,
343 code: Code,
344 message: String,
345 span: Span,
346 help: String,
347 ) {
348 self.diagnostics.push(TypeDiagnostic {
349 code,
350 message,
351 severity: DiagnosticSeverity::Error,
352 span: Some(span),
353 help: Some(help),
354 related: Vec::new(),
355 fix: None,
356 details: None,
357 repair: default_repair(code),
358 });
359 }
360
361 pub(in crate::typechecker) fn type_mismatch_at(
362 &mut self,
363 code: Code,
364 context: impl Into<String>,
365 expected: &TypeExpr,
366 actual: &TypeExpr,
367 span: Span,
368 evidence: TypeMismatchEvidence,
369 scope: &TypeScope,
370 ) {
371 let (expected_origin, value_span) = evidence;
372 let nested_mismatch = first_nested_mismatch(expected, actual, scope);
373 let mut message = format!(
374 "{}: expected {}, found {}",
375 context.into(),
376 format_type(expected),
377 format_type(actual)
378 );
379 if let Some(detail) = shape_mismatch_detail(expected, actual)
380 .or_else(|| nested_mismatch.as_ref().map(|note| note.message.clone()))
381 {
382 message.push_str(&format!(" ({detail})"));
383 }
384
385 let mut related = Vec::new();
386 if let Some((span, message)) = expected_origin {
387 related.push(RelatedDiagnostic { span, message });
388 }
389 if let Some(note) = nested_mismatch {
390 related.push(RelatedDiagnostic {
391 span,
392 message: format!("nested mismatch: {}", note.message),
393 });
394 }
395
396 self.diagnostics.push(TypeDiagnostic {
397 code,
398 message,
399 severity: DiagnosticSeverity::Error,
400 span: Some(span),
401 help: coercion_suggestion(expected, actual, value_span, self.source.as_deref()),
402 related,
403 fix: None,
404 details: Some(DiagnosticDetails::TypeMismatch),
405 repair: default_repair(code),
406 });
407 }
408
409 pub(in crate::typechecker) fn error_at_with_fix(
410 &mut self,
411 code: Code,
412 message: String,
413 span: Span,
414 fix: Vec<FixEdit>,
415 ) {
416 self.diagnostics.push(TypeDiagnostic {
417 code,
418 message,
419 severity: DiagnosticSeverity::Error,
420 span: Some(span),
421 help: None,
422 related: Vec::new(),
423 fix: Some(fix),
424 details: None,
425 repair: default_repair(code),
426 });
427 }
428
429 pub(in crate::typechecker) fn exhaustiveness_error_at(
436 &mut self,
437 code: Code,
438 message: String,
439 span: Span,
440 ) {
441 self.diagnostics.push(TypeDiagnostic {
442 code,
443 message,
444 severity: DiagnosticSeverity::Error,
445 span: Some(span),
446 help: None,
447 related: Vec::new(),
448 fix: None,
449 details: None,
450 repair: default_repair(code),
451 });
452 }
453
454 pub(in crate::typechecker) fn exhaustiveness_error_with_missing(
459 &mut self,
460 code: Code,
461 message: String,
462 span: Span,
463 missing: Vec<String>,
464 ) {
465 self.diagnostics.push(TypeDiagnostic {
466 code,
467 message,
468 severity: DiagnosticSeverity::Error,
469 span: Some(span),
470 help: None,
471 related: Vec::new(),
472 fix: None,
473 details: Some(DiagnosticDetails::NonExhaustiveMatch { missing }),
474 repair: default_repair(code),
475 });
476 }
477
478 pub(in crate::typechecker) fn warning_at(&mut self, code: Code, message: String, span: Span) {
479 self.diagnostics.push(TypeDiagnostic {
480 code,
481 message,
482 severity: DiagnosticSeverity::Warning,
483 span: Some(span),
484 help: None,
485 related: Vec::new(),
486 fix: None,
487 details: None,
488 repair: default_repair(code),
489 });
490 }
491
492 #[allow(dead_code)]
493 pub(in crate::typechecker) fn warning_at_with_help(
494 &mut self,
495 code: Code,
496 message: String,
497 span: Span,
498 help: String,
499 ) {
500 self.diagnostics.push(TypeDiagnostic {
501 code,
502 message,
503 severity: DiagnosticSeverity::Warning,
504 span: Some(span),
505 help: Some(help),
506 related: Vec::new(),
507 fix: None,
508 details: None,
509 repair: default_repair(code),
510 });
511 }
512
513 pub(in crate::typechecker) fn lint_warning_at_with_fix(
514 &mut self,
515 code: Code,
516 rule: &'static str,
517 message: String,
518 span: Span,
519 help: String,
520 fix: Vec<FixEdit>,
521 ) {
522 self.diagnostics.push(TypeDiagnostic {
523 code,
524 message,
525 severity: DiagnosticSeverity::Warning,
526 span: Some(span),
527 help: Some(help),
528 related: Vec::new(),
529 fix: Some(fix),
530 details: Some(DiagnosticDetails::LintRule { rule }),
531 repair: default_repair(code),
532 });
533 }
534}
535
536pub(crate) fn default_repair(code: Code) -> Option<Repair> {
541 code.repair_template().map(Repair::from_template)
542}
543
544#[derive(Debug)]
545struct MismatchNote {
546 message: String,
547}
548
549fn first_nested_mismatch(
550 expected: &TypeExpr,
551 actual: &TypeExpr,
552 scope: &TypeScope,
553) -> Option<MismatchNote> {
554 let expected = resolve_type_for_diagnostic(expected, scope);
555 let actual = resolve_type_for_diagnostic(actual, scope);
556 match (&expected, &actual) {
557 (TypeExpr::Shape(expected_fields), TypeExpr::Shape(actual_fields)) => {
558 for expected_field in expected_fields {
559 if expected_field.optional {
560 continue;
561 }
562 let Some(actual_field) = actual_fields
563 .iter()
564 .find(|actual_field| actual_field.name == expected_field.name)
565 else {
566 return Some(MismatchNote {
567 message: format!(
568 "field `{}` is missing; expected {}",
569 expected_field.name,
570 format_type(&expected_field.type_expr)
571 ),
572 });
573 };
574 if !types_compatible_for_diagnostic(
575 &expected_field.type_expr,
576 &actual_field.type_expr,
577 scope,
578 ) {
579 return Some(MismatchNote {
580 message: format!(
581 "field `{}` expected {}, found {}",
582 expected_field.name,
583 format_type(&expected_field.type_expr),
584 format_type(&actual_field.type_expr)
585 ),
586 });
587 }
588 }
589 None
590 }
591 (TypeExpr::List(expected_inner), TypeExpr::List(actual_inner)) => {
592 if !types_compatible_for_diagnostic(expected_inner, actual_inner, scope)
593 || !types_compatible_for_diagnostic(actual_inner, expected_inner, scope)
594 {
595 Some(MismatchNote {
596 message: format!(
597 "list element expected {}, found {}",
598 format_type(expected_inner),
599 format_type(actual_inner)
600 ),
601 })
602 } else {
603 None
604 }
605 }
606 (
607 TypeExpr::DictType(expected_key, expected_value),
608 TypeExpr::DictType(actual_key, actual_value),
609 ) => {
610 if !types_compatible_for_diagnostic(expected_key, actual_key, scope)
611 || !types_compatible_for_diagnostic(actual_key, expected_key, scope)
612 {
613 Some(MismatchNote {
614 message: format!(
615 "dict key expected {}, found {}",
616 format_type(expected_key),
617 format_type(actual_key)
618 ),
619 })
620 } else if !types_compatible_for_diagnostic(expected_value, actual_value, scope)
621 || !types_compatible_for_diagnostic(actual_value, expected_value, scope)
622 {
623 Some(MismatchNote {
624 message: format!(
625 "dict value expected {}, found {}",
626 format_type(expected_value),
627 format_type(actual_value)
628 ),
629 })
630 } else {
631 None
632 }
633 }
634 (
635 TypeExpr::Applied {
636 name: expected_name,
637 args: expected_args,
638 },
639 TypeExpr::Applied {
640 name: actual_name,
641 args: actual_args,
642 },
643 ) if expected_name == actual_name => expected_args
644 .iter()
645 .zip(actual_args.iter())
646 .enumerate()
647 .find_map(|(idx, (expected_arg, actual_arg))| {
648 if types_compatible_for_diagnostic(expected_arg, actual_arg, scope)
649 && types_compatible_for_diagnostic(actual_arg, expected_arg, scope)
650 {
651 None
652 } else {
653 Some(MismatchNote {
654 message: format!(
655 "{} type argument {} expected {}, found {}",
656 expected_name,
657 idx + 1,
658 format_type(expected_arg),
659 format_type(actual_arg)
660 ),
661 })
662 }
663 }),
664 (
665 TypeExpr::FnType {
666 params: expected_params,
667 return_type: expected_return,
668 },
669 TypeExpr::FnType {
670 params: actual_params,
671 return_type: actual_return,
672 },
673 ) => {
674 for (idx, (expected_param, actual_param)) in
675 expected_params.iter().zip(actual_params.iter()).enumerate()
676 {
677 if !types_compatible_for_diagnostic(actual_param, expected_param, scope) {
678 return Some(MismatchNote {
679 message: format!(
680 "function parameter {} expected {}, found {}",
681 idx + 1,
682 format_type(expected_param),
683 format_type(actual_param)
684 ),
685 });
686 }
687 }
688 if !types_compatible_for_diagnostic(expected_return, actual_return, scope) {
689 Some(MismatchNote {
690 message: format!(
691 "function return expected {}, found {}",
692 format_type(expected_return),
693 format_type(actual_return)
694 ),
695 })
696 } else {
697 None
698 }
699 }
700 _ => None,
701 }
702}
703
704fn types_compatible_for_diagnostic(
705 expected: &TypeExpr,
706 actual: &TypeExpr,
707 scope: &TypeScope,
708) -> bool {
709 TypeChecker::new().types_compatible(expected, actual, scope)
710}
711
712fn resolve_type_for_diagnostic(ty: &TypeExpr, scope: &TypeScope) -> TypeExpr {
713 TypeChecker::new().resolve_alias(ty, scope)
714}
715
716fn coercion_suggestion(
717 expected: &TypeExpr,
718 actual: &TypeExpr,
719 value_span: Option<Span>,
720 source: Option<&str>,
721) -> Option<String> {
722 let expr = value_span
723 .and_then(|span| source.and_then(|source| source.get(span.start..span.end)))
724 .map(str::trim)
725 .filter(|expr| !expr.is_empty());
726 if is_nilable(actual) {
727 return Some("handle `nil` first or provide a default with `??`".to_string());
728 }
729 let expected_ty = expected;
730 let expected = simple_type_name(expected)?;
731 let actual_name = simple_type_name(actual)?;
732 let with_expr = |template: &str| {
733 expr.map(|expr| template.replace("{}", expr))
734 .unwrap_or_else(|| template.replace("{}", "value"))
735 };
736
737 match (expected, actual_name) {
738 ("string", "int" | "float" | "bool" | "nil" | "duration") => {
739 Some(format!("did you mean `{}`?", with_expr("to_string({})")))
740 }
741 ("int", "string") => Some(format!("did you mean `{}`?", with_expr("to_int({})"))),
742 ("float", "string" | "int") => {
743 Some(format!("did you mean `{}`?", with_expr("to_float({})")))
744 }
745 (_, "nil") => Some("handle `nil` first or provide a default with `??`".to_string()),
746 _ if actual_is_result_of(expected_ty, actual) => Some(format!(
747 "did you mean `{}` or `{}`?",
748 with_expr("{}?"),
749 with_expr("unwrap_or({}, default)")
750 )),
751 _ => None,
752 }
753}
754
755fn simple_type_name(ty: &TypeExpr) -> Option<&str> {
756 match ty {
757 TypeExpr::Named(name) => Some(name.as_str()),
758 TypeExpr::LitString(_) => Some("string"),
759 TypeExpr::LitInt(_) => Some("int"),
760 _ => None,
761 }
762}
763
764fn is_nilable(ty: &TypeExpr) -> bool {
765 match ty {
766 TypeExpr::Union(members) if members.len() == 2 => members
767 .iter()
768 .any(|member| matches!(member, TypeExpr::Named(name) if name == "nil")),
769 _ => false,
770 }
771}
772
773fn actual_is_result_of(expected: &TypeExpr, actual: &TypeExpr) -> bool {
774 matches!(
775 actual,
776 TypeExpr::Applied { name, args }
777 if name == "Result" && args.first().is_some_and(|ok| ok == expected)
778 )
779}
780
781impl Default for TypeChecker {
782 fn default() -> Self {
783 Self::new()
784 }
785}
786
787#[cfg(test)]
788mod tests;