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