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