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