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    /// Machine-applicable fix edits.
39    pub fix: Option<Vec<FixEdit>>,
40    /// Optional structured payload that higher-level tooling (e.g. the
41    /// LSP code-action provider) can consume to synthesise fixes that
42    /// need more than a static `FixEdit`. Out-of-band from `fix` so the
43    /// string-based rendering pipeline doesn't have to care.
44    pub details: Option<DiagnosticDetails>,
45}
46
47/// Optional structured companion data on a `TypeDiagnostic`. The
48/// variants map one-to-one with diagnostics that have specific
49/// tooling-consumable state beyond the human-readable message; each
50/// variant is attached only by the sites that produce its
51/// corresponding diagnostic, so a consumer can pattern-match on the
52/// variant without parsing the error string.
53#[derive(Debug, Clone)]
54pub enum DiagnosticDetails {
55    /// A `match` expression with missing variant coverage. `missing`
56    /// holds the formatted literal values of each uncovered variant
57    /// (quoted for strings, bare for ints), ready to drop into a new
58    /// arm prefix. The diagnostic's `span` covers the whole `match`
59    /// expression, so a code-action can locate the closing `}` by
60    /// reading the source at `span.end`.
61    NonExhaustiveMatch { missing: Vec<String> },
62}
63
64#[derive(Debug, Clone, Copy, PartialEq, Eq)]
65pub enum DiagnosticSeverity {
66    Error,
67    Warning,
68}
69
70/// The static type checker.
71pub struct TypeChecker {
72    diagnostics: Vec<TypeDiagnostic>,
73    scope: TypeScope,
74    source: Option<String>,
75    hints: Vec<InlayHintInfo>,
76    /// When true, flag unvalidated boundary-API values used in field access.
77    strict_types: bool,
78    /// Lexical depth of enclosing function-like bodies (fn/tool/pipeline/closure).
79    /// `try*` requires `fn_depth > 0` so the rethrow has a body to live in.
80    fn_depth: usize,
81    /// Lexical depth of enclosing `gen fn` bodies. `emit` is only valid here.
82    stream_fn_depth: usize,
83    /// Expected emitted value type for each enclosing `gen fn`.
84    stream_emit_types: Vec<Option<TypeExpr>>,
85    /// Maps function name -> deprecation metadata `(since, use_hint)`. Populated
86    /// when an `@deprecated` attribute is encountered on a top-level fn decl
87    /// during the `check_inner` pre-pass; consulted at every `FunctionCall`
88    /// site to emit a warning + help line.
89    deprecated_fns: std::collections::HashMap<String, (Option<String>, Option<String>)>,
90    /// Names statically known to be introduced by cross-module imports
91    /// (resolved via `harn-modules`). `Some(set)` switches the checker into
92    /// strict cross-module mode: an unresolved callable name is reported as
93    /// an error instead of silently passing through. `None` preserves the
94    /// conservative pre-v0.7.12 behavior (no cross-module undefined-name
95    /// diagnostics).
96    imported_names: Option<HashSet<String>>,
97    /// Type-like declarations imported from other modules. These are registered
98    /// into the scope before local checking so imported type aliases and tagged
99    /// unions participate in normal field access and narrowing.
100    imported_type_decls: Vec<SNode>,
101}
102
103impl TypeChecker {
104    pub(in crate::typechecker) fn wildcard_type() -> TypeExpr {
105        TypeExpr::Named("_".into())
106    }
107
108    pub(in crate::typechecker) fn is_wildcard_type(ty: &TypeExpr) -> bool {
109        matches!(ty, TypeExpr::Named(name) if name == "_")
110    }
111
112    pub(in crate::typechecker) fn base_type_name(ty: &TypeExpr) -> Option<&str> {
113        match ty {
114            TypeExpr::Named(name) => Some(name.as_str()),
115            TypeExpr::Applied { name, .. } => Some(name.as_str()),
116            _ => None,
117        }
118    }
119
120    pub fn new() -> Self {
121        Self {
122            diagnostics: Vec::new(),
123            scope: TypeScope::new(),
124            source: None,
125            hints: Vec::new(),
126            strict_types: false,
127            fn_depth: 0,
128            stream_fn_depth: 0,
129            stream_emit_types: Vec::new(),
130            deprecated_fns: std::collections::HashMap::new(),
131            imported_names: None,
132            imported_type_decls: Vec::new(),
133        }
134    }
135
136    /// Create a type checker with strict types mode.
137    /// When enabled, flags unvalidated boundary-API values used in field access.
138    pub fn with_strict_types(strict: bool) -> Self {
139        Self {
140            diagnostics: Vec::new(),
141            scope: TypeScope::new(),
142            source: None,
143            hints: Vec::new(),
144            strict_types: strict,
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        }
152    }
153
154    /// Attach the set of names statically introduced by cross-module imports.
155    ///
156    /// Enables strict cross-module undefined-call errors: call sites that are
157    /// not builtins, not local declarations, not struct constructors, not
158    /// callable variables, and not in `imported` will produce a type error.
159    ///
160    /// Passing `None` (the default) preserves pre-v0.7.12 behavior where
161    /// unresolved call names only surface via lint diagnostics. Callers
162    /// should only pass `Some(set)` when every import in the file resolved
163    /// — see `harn_modules::ModuleGraph::imported_names_for_file`.
164    pub fn with_imported_names(mut self, imported: HashSet<String>) -> Self {
165        self.imported_names = Some(imported);
166        self
167    }
168
169    /// Attach imported type / struct / enum / interface declarations. The
170    /// caller is responsible for resolving module imports and filtering the
171    /// visible declarations before passing them in.
172    pub fn with_imported_type_decls(mut self, imported: Vec<SNode>) -> Self {
173        self.imported_type_decls = imported;
174        self
175    }
176
177    /// Check a program with source text for autofix generation.
178    pub fn check_with_source(mut self, program: &[SNode], source: &str) -> Vec<TypeDiagnostic> {
179        self.source = Some(source.to_string());
180        self.check_inner(program).0
181    }
182
183    /// Check a program with strict types mode and source text.
184    pub fn check_strict_with_source(
185        mut self,
186        program: &[SNode],
187        source: &str,
188    ) -> Vec<TypeDiagnostic> {
189        self.source = Some(source.to_string());
190        self.check_inner(program).0
191    }
192
193    /// Check a program and return diagnostics.
194    pub fn check(self, program: &[SNode]) -> Vec<TypeDiagnostic> {
195        self.check_inner(program).0
196    }
197
198    /// Check whether a function call value is a boundary source that produces
199    /// unvalidated data.  Returns `None` if the value is type-safe
200    /// (e.g. llm_call with a schema option, or a non-boundary function).
201    pub(in crate::typechecker) fn detect_boundary_source(
202        value: &SNode,
203        scope: &TypeScope,
204    ) -> Option<String> {
205        match &value.node {
206            Node::FunctionCall { name, args } => {
207                if !builtin_signatures::is_untyped_boundary_source(name) {
208                    return None;
209                }
210                // llm_call/llm_completion with a schema option are type-safe
211                if (name == "llm_call" || name == "llm_completion")
212                    && Self::llm_call_has_typed_schema_option(args, scope)
213                {
214                    return None;
215                }
216                Some(name.clone())
217            }
218            Node::Identifier(name) => scope.is_untyped_source(name).map(|s| s.to_string()),
219            _ => None,
220        }
221    }
222
223    /// True if an `llm_call` / `llm_completion` options dict names a
224    /// resolvable output schema. Used by the strict-types boundary checks
225    /// to suppress "unvalidated" warnings when the call site is typed.
226    /// Actual return-type narrowing is driven by the generic-builtin
227    /// dispatch path in `infer_type`, not this helper.
228    pub(in crate::typechecker) fn llm_call_has_typed_schema_option(
229        args: &[SNode],
230        scope: &TypeScope,
231    ) -> bool {
232        let Some(opts) = args.get(2) else {
233            return false;
234        };
235        let Node::DictLiteral(entries) = &opts.node else {
236            return false;
237        };
238        entries.iter().any(|entry| {
239            let key = match &entry.key.node {
240                Node::StringLiteral(k) | Node::Identifier(k) => k.as_str(),
241                _ => return false,
242            };
243            (key == "schema" || key == "output_schema")
244                && schema_type_expr_from_node(&entry.value, scope).is_some()
245        })
246    }
247
248    /// Check whether a type annotation is a concrete shape/struct type
249    /// (as opposed to bare `dict` or no annotation).
250    pub(in crate::typechecker) fn is_concrete_type(ty: &TypeExpr) -> bool {
251        matches!(
252            ty,
253            TypeExpr::Shape(_)
254                | TypeExpr::Applied { .. }
255                | TypeExpr::FnType { .. }
256                | TypeExpr::List(_)
257                | TypeExpr::Iter(_)
258                | TypeExpr::Generator(_)
259                | TypeExpr::Stream(_)
260                | TypeExpr::DictType(_, _)
261        ) || matches!(ty, TypeExpr::Named(n) if n != "dict" && n != "any" && n != "_")
262    }
263
264    /// Check a program and return both diagnostics and inlay hints.
265    pub fn check_with_hints(
266        mut self,
267        program: &[SNode],
268        source: &str,
269    ) -> (Vec<TypeDiagnostic>, Vec<InlayHintInfo>) {
270        self.source = Some(source.to_string());
271        self.check_inner(program)
272    }
273
274    pub(in crate::typechecker) fn error_at(&mut self, message: String, span: Span) {
275        self.diagnostics.push(TypeDiagnostic {
276            message,
277            severity: DiagnosticSeverity::Error,
278            span: Some(span),
279            help: None,
280            fix: None,
281            details: None,
282        });
283    }
284
285    #[allow(dead_code)]
286    pub(in crate::typechecker) fn error_at_with_help(
287        &mut self,
288        message: String,
289        span: Span,
290        help: String,
291    ) {
292        self.diagnostics.push(TypeDiagnostic {
293            message,
294            severity: DiagnosticSeverity::Error,
295            span: Some(span),
296            help: Some(help),
297            fix: None,
298            details: None,
299        });
300    }
301
302    pub(in crate::typechecker) fn error_at_with_fix(
303        &mut self,
304        message: String,
305        span: Span,
306        fix: Vec<FixEdit>,
307    ) {
308        self.diagnostics.push(TypeDiagnostic {
309            message,
310            severity: DiagnosticSeverity::Error,
311            span: Some(span),
312            help: None,
313            fix: Some(fix),
314            details: None,
315        });
316    }
317
318    /// Diagnostic site for non-exhaustive `match` arms. Match arms must be
319    /// exhaustive — a missing-variant `match` is a hard error. Authors who
320    /// genuinely want partial coverage opt out with a wildcard `_` arm.
321    /// Partial `if/elif/else` chains are intentionally allowed and are
322    /// instead handled by `check_unknown_exhaustiveness`, which stays a
323    /// warning so the `unreachable()` opt-in pattern continues to work.
324    pub(in crate::typechecker) fn exhaustiveness_error_at(&mut self, message: String, span: Span) {
325        self.diagnostics.push(TypeDiagnostic {
326            message,
327            severity: DiagnosticSeverity::Error,
328            span: Some(span),
329            help: None,
330            fix: None,
331            details: None,
332        });
333    }
334
335    /// Like `exhaustiveness_error_at` but additionally attaches the
336    /// missing-variant list as structured details. LSP code-actions
337    /// read this to synthesise an "Add missing match arms" quick-fix
338    /// without string-parsing the message.
339    pub(in crate::typechecker) fn exhaustiveness_error_with_missing(
340        &mut self,
341        message: String,
342        span: Span,
343        missing: Vec<String>,
344    ) {
345        self.diagnostics.push(TypeDiagnostic {
346            message,
347            severity: DiagnosticSeverity::Error,
348            span: Some(span),
349            help: None,
350            fix: None,
351            details: Some(DiagnosticDetails::NonExhaustiveMatch { missing }),
352        });
353    }
354
355    pub(in crate::typechecker) fn warning_at(&mut self, message: String, span: Span) {
356        self.diagnostics.push(TypeDiagnostic {
357            message,
358            severity: DiagnosticSeverity::Warning,
359            span: Some(span),
360            help: None,
361            fix: None,
362            details: None,
363        });
364    }
365
366    #[allow(dead_code)]
367    pub(in crate::typechecker) fn warning_at_with_help(
368        &mut self,
369        message: String,
370        span: Span,
371        help: String,
372    ) {
373        self.diagnostics.push(TypeDiagnostic {
374            message,
375            severity: DiagnosticSeverity::Warning,
376            span: Some(span),
377            help: Some(help),
378            fix: None,
379            details: None,
380        });
381    }
382}
383
384impl Default for TypeChecker {
385    fn default() -> Self {
386        Self::new()
387    }
388}
389
390#[cfg(test)]
391mod tests;