Skip to main content

harn_parser/typechecker/
mod.rs

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/// An inlay hint produced during type checking.
22#[derive(Debug, Clone)]
23pub struct InlayHintInfo {
24    /// Position (line, column) where the hint should be displayed (after the variable name).
25    pub line: usize,
26    pub column: usize,
27    /// The type label to display (e.g. ": string").
28    pub label: String,
29}
30
31/// A diagnostic produced by the type checker.
32#[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    /// Machine-applicable fix edits.
40    pub fix: Option<Vec<FixEdit>>,
41    /// Optional structured payload that higher-level tooling (e.g. the
42    /// LSP code-action provider) can consume to synthesise fixes that
43    /// need more than a static `FixEdit`. Out-of-band from `fix` so the
44    /// string-based rendering pipeline doesn't have to care.
45    pub details: Option<DiagnosticDetails>,
46}
47
48#[derive(Debug, Clone)]
49pub struct RelatedDiagnostic {
50    pub span: Span,
51    pub message: String,
52}
53
54/// Optional structured companion data on a `TypeDiagnostic`. The
55/// variants map one-to-one with diagnostics that have specific
56/// tooling-consumable state beyond the human-readable message; each
57/// variant is attached only by the sites that produce its
58/// corresponding diagnostic, so a consumer can pattern-match on the
59/// variant without parsing the error string.
60#[derive(Debug, Clone)]
61pub enum DiagnosticDetails {
62    /// A `match` expression with missing variant coverage. `missing`
63    /// holds the formatted literal values of each uncovered variant
64    /// (quoted for strings, bare for ints), ready to drop into a new
65    /// arm prefix. The diagnostic's `span` covers the whole `match`
66    /// expression, so a code-action can locate the closing `}` by
67    /// reading the source at `span.end`.
68    NonExhaustiveMatch { missing: Vec<String> },
69}
70
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72pub enum DiagnosticSeverity {
73    Error,
74    Warning,
75}
76
77/// The static type checker.
78pub struct TypeChecker {
79    diagnostics: Vec<TypeDiagnostic>,
80    scope: TypeScope,
81    source: Option<String>,
82    hints: Vec<InlayHintInfo>,
83    /// When true, flag unvalidated boundary-API values used in field access.
84    strict_types: bool,
85    /// Lexical depth of enclosing function-like bodies (fn/tool/pipeline/closure).
86    /// `try*` requires `fn_depth > 0` so the rethrow has a body to live in.
87    fn_depth: usize,
88    /// Lexical depth of enclosing `gen fn` bodies. `emit` is only valid here.
89    stream_fn_depth: usize,
90    /// Expected emitted value type for each enclosing `gen fn`.
91    stream_emit_types: Vec<Option<TypeExpr>>,
92    /// Maps function name -> deprecation metadata `(since, use_hint)`. Populated
93    /// when an `@deprecated` attribute is encountered on a top-level fn decl
94    /// during the `check_inner` pre-pass; consulted at every `FunctionCall`
95    /// site to emit a warning + help line.
96    deprecated_fns: std::collections::HashMap<String, (Option<String>, Option<String>)>,
97    /// Names statically known to be introduced by cross-module imports
98    /// (resolved via `harn-modules`). `Some(set)` switches the checker into
99    /// strict cross-module mode: an unresolved callable name is reported as
100    /// an error instead of silently passing through. `None` preserves the
101    /// conservative pre-v0.7.12 behavior (no cross-module undefined-name
102    /// diagnostics).
103    imported_names: Option<HashSet<String>>,
104    /// Type-like declarations imported from other modules. These are registered
105    /// into the scope before local checking so imported type aliases and tagged
106    /// unions participate in normal field access and narrowing.
107    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    /// Create a type checker with strict types mode.
144    /// When enabled, flags unvalidated boundary-API values used in field access.
145    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    /// Attach the set of names statically introduced by cross-module imports.
162    ///
163    /// Enables strict cross-module undefined-call errors: call sites that are
164    /// not builtins, not local declarations, not struct constructors, not
165    /// callable variables, and not in `imported` will produce a type error.
166    ///
167    /// Passing `None` (the default) preserves pre-v0.7.12 behavior where
168    /// unresolved call names only surface via lint diagnostics. Callers
169    /// should only pass `Some(set)` when every import in the file resolved
170    /// — see `harn_modules::ModuleGraph::imported_names_for_file`.
171    pub fn with_imported_names(mut self, imported: HashSet<String>) -> Self {
172        self.imported_names = Some(imported);
173        self
174    }
175
176    /// Attach imported type / struct / enum / interface declarations. The
177    /// caller is responsible for resolving module imports and filtering the
178    /// visible declarations before passing them in.
179    pub fn with_imported_type_decls(mut self, imported: Vec<SNode>) -> Self {
180        self.imported_type_decls = imported;
181        self
182    }
183
184    /// Check a program with source text for autofix generation.
185    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    /// Check a program with strict types mode and source text.
191    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    /// Check a program and return diagnostics.
201    pub fn check(self, program: &[SNode]) -> Vec<TypeDiagnostic> {
202        self.check_inner(program).0
203    }
204
205    /// Check whether a function call value is a boundary source that produces
206    /// unvalidated data.  Returns `None` if the value is type-safe
207    /// (e.g. llm_call with a schema option, or a non-boundary function).
208    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                // llm_call/llm_completion with a schema option are type-safe
218                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    /// True if an `llm_call` / `llm_completion` options dict names a
231    /// resolvable output schema. Used by the strict-types boundary checks
232    /// to suppress "unvalidated" warnings when the call site is typed.
233    /// Actual return-type narrowing is driven by the generic-builtin
234    /// dispatch path in `infer_type`, not this helper.
235    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    /// Check whether a type annotation is a concrete shape/struct type
256    /// (as opposed to bare `dict` or no annotation).
257    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    /// Check a program and return both diagnostics and inlay hints.
272    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    /// Diagnostic site for non-exhaustive `match` arms. Match arms must be
374    /// exhaustive — a missing-variant `match` is a hard error. Authors who
375    /// genuinely want partial coverage opt out with a wildcard `_` arm.
376    /// Partial `if/elif/else` chains are intentionally allowed and are
377    /// instead handled by `check_unknown_exhaustiveness`, which stays a
378    /// warning so the `unreachable()` opt-in pattern continues to work.
379    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    /// Like `exhaustiveness_error_at` but additionally attaches the
392    /// missing-variant list as structured details. LSP code-actions
393    /// read this to synthesise an "Add missing match arms" quick-fix
394    /// without string-parsing the message.
395    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;