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