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