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 concrete expected/found mismatch. Renderers can use this to
63    /// provide stable labels without scraping human-readable text.
64    TypeMismatch,
65    /// A `match` expression with missing variant coverage. `missing`
66    /// holds the formatted literal values of each uncovered variant
67    /// (quoted for strings, bare for ints), ready to drop into a new
68    /// arm prefix. The diagnostic's `span` covers the whole `match`
69    /// expression, so a code-action can locate the closing `}` by
70    /// reading the source at `span.end`.
71    NonExhaustiveMatch { missing: Vec<String> },
72    /// A type-aware lint diagnostic. These diagnostics are produced by
73    /// the type checker because the rule depends on flow-sensitive type
74    /// information, but `harn lint` should surface and filter them like
75    /// ordinary lint rules.
76    LintRule { rule: &'static str },
77}
78
79#[derive(Debug, Clone, Copy, PartialEq, Eq)]
80pub enum DiagnosticSeverity {
81    Error,
82    Warning,
83}
84
85/// The static type checker.
86pub struct TypeChecker {
87    diagnostics: Vec<TypeDiagnostic>,
88    scope: TypeScope,
89    source: Option<String>,
90    hints: Vec<InlayHintInfo>,
91    /// When true, flag unvalidated boundary-API values used in field access.
92    strict_types: bool,
93    /// Lexical depth of enclosing function-like bodies (fn/tool/pipeline/closure).
94    /// `try*` requires `fn_depth > 0` so the rethrow has a body to live in.
95    fn_depth: usize,
96    /// Lexical depth of enclosing `gen fn` bodies. `emit` is only valid here.
97    stream_fn_depth: usize,
98    /// Expected emitted value type for each enclosing `gen fn`.
99    stream_emit_types: Vec<Option<TypeExpr>>,
100    /// Maps function name -> deprecation metadata `(since, use_hint)`. Populated
101    /// when an `@deprecated` attribute is encountered on a top-level fn decl
102    /// during the `check_inner` pre-pass; consulted at every `FunctionCall`
103    /// site to emit a warning + help line.
104    deprecated_fns: std::collections::HashMap<String, (Option<String>, Option<String>)>,
105    /// Names statically known to be introduced by cross-module imports
106    /// (resolved via `harn-modules`). `Some(set)` switches the checker into
107    /// strict cross-module mode: an unresolved callable name is reported as
108    /// an error instead of silently passing through. `None` preserves the
109    /// conservative pre-v0.7.12 behavior (no cross-module undefined-name
110    /// diagnostics).
111    imported_names: Option<HashSet<String>>,
112    /// Type-like declarations imported from other modules. These are registered
113    /// into the scope before local checking so imported type aliases and tagged
114    /// unions participate in normal field access and narrowing.
115    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    /// Create a type checker with strict types mode.
152    /// When enabled, flags unvalidated boundary-API values used in field access.
153    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    /// Attach the set of names statically introduced by cross-module imports.
170    ///
171    /// Enables strict cross-module undefined-call errors: call sites that are
172    /// not builtins, not local declarations, not struct constructors, not
173    /// callable variables, and not in `imported` will produce a type error.
174    ///
175    /// Passing `None` (the default) preserves pre-v0.7.12 behavior where
176    /// unresolved call names only surface via lint diagnostics. Callers
177    /// should only pass `Some(set)` when every import in the file resolved
178    /// — see `harn_modules::ModuleGraph::imported_names_for_file`.
179    pub fn with_imported_names(mut self, imported: HashSet<String>) -> Self {
180        self.imported_names = Some(imported);
181        self
182    }
183
184    /// Attach imported type / struct / enum / interface declarations. The
185    /// caller is responsible for resolving module imports and filtering the
186    /// visible declarations before passing them in.
187    pub fn with_imported_type_decls(mut self, imported: Vec<SNode>) -> Self {
188        self.imported_type_decls = imported;
189        self
190    }
191
192    /// Check a program with source text for autofix generation.
193    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    /// Check a program with strict types mode and source text.
199    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    /// Check a program and return diagnostics.
209    pub fn check(self, program: &[SNode]) -> Vec<TypeDiagnostic> {
210        self.check_inner(program).0
211    }
212
213    /// Check whether a function call value is a boundary source that produces
214    /// unvalidated data.  Returns `None` if the value is type-safe
215    /// (e.g. llm_call with a schema option, or a non-boundary function).
216    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                // llm_call/llm_completion with a schema option are type-safe
226                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    /// True if an `llm_call` / `llm_completion` options dict names a
239    /// resolvable output schema. Used by the strict-types boundary checks
240    /// to suppress "unvalidated" warnings when the call site is typed.
241    /// Actual return-type narrowing is driven by the generic-builtin
242    /// dispatch path in `infer_type`, not this helper.
243    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    /// Check whether a type annotation is a concrete shape/struct type
264    /// (as opposed to bare `dict` or no annotation).
265    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    /// Check a program and return both diagnostics and inlay hints.
280    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    /// Diagnostic site for non-exhaustive `match` arms. Match arms must be
382    /// exhaustive — a missing-variant `match` is a hard error. Authors who
383    /// genuinely want partial coverage opt out with a wildcard `_` arm.
384    /// Partial `if/elif/else` chains are intentionally allowed and are
385    /// instead handled by `check_unknown_exhaustiveness`, which stays a
386    /// warning so the `unreachable()` opt-in pattern continues to work.
387    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    /// Like `exhaustiveness_error_at` but additionally attaches the
400    /// missing-variant list as structured details. LSP code-actions
401    /// read this to synthesise an "Add missing match arms" quick-fix
402    /// without string-parsing the message.
403    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;