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}
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum DiagnosticSeverity {
44    Error,
45    Warning,
46}
47
48/// The static type checker.
49pub struct TypeChecker {
50    diagnostics: Vec<TypeDiagnostic>,
51    scope: TypeScope,
52    source: Option<String>,
53    hints: Vec<InlayHintInfo>,
54    /// When true, flag unvalidated boundary-API values used in field access.
55    strict_types: bool,
56    /// Lexical depth of enclosing function-like bodies (fn/tool/pipeline/closure).
57    /// `try*` requires `fn_depth > 0` so the rethrow has a body to live in.
58    fn_depth: usize,
59    /// Maps function name -> deprecation metadata `(since, use_hint)`. Populated
60    /// when an `@deprecated` attribute is encountered on a top-level fn decl
61    /// during the `check_inner` pre-pass; consulted at every `FunctionCall`
62    /// site to emit a warning + help line.
63    deprecated_fns: std::collections::HashMap<String, (Option<String>, Option<String>)>,
64    /// Names statically known to be introduced by cross-module imports
65    /// (resolved via `harn-modules`). `Some(set)` switches the checker into
66    /// strict cross-module mode: an unresolved callable name is reported as
67    /// an error instead of silently passing through. `None` preserves the
68    /// conservative pre-v0.7.12 behavior (no cross-module undefined-name
69    /// diagnostics).
70    imported_names: Option<HashSet<String>>,
71}
72
73impl TypeChecker {
74    pub(in crate::typechecker) fn wildcard_type() -> TypeExpr {
75        TypeExpr::Named("_".into())
76    }
77
78    pub(in crate::typechecker) fn is_wildcard_type(ty: &TypeExpr) -> bool {
79        matches!(ty, TypeExpr::Named(name) if name == "_")
80    }
81
82    pub(in crate::typechecker) fn base_type_name(ty: &TypeExpr) -> Option<&str> {
83        match ty {
84            TypeExpr::Named(name) => Some(name.as_str()),
85            TypeExpr::Applied { name, .. } => Some(name.as_str()),
86            _ => None,
87        }
88    }
89
90    pub fn new() -> Self {
91        Self {
92            diagnostics: Vec::new(),
93            scope: TypeScope::new(),
94            source: None,
95            hints: Vec::new(),
96            strict_types: false,
97            fn_depth: 0,
98            deprecated_fns: std::collections::HashMap::new(),
99            imported_names: None,
100        }
101    }
102
103    /// Create a type checker with strict types mode.
104    /// When enabled, flags unvalidated boundary-API values used in field access.
105    pub fn with_strict_types(strict: bool) -> Self {
106        Self {
107            diagnostics: Vec::new(),
108            scope: TypeScope::new(),
109            source: None,
110            hints: Vec::new(),
111            strict_types: strict,
112            fn_depth: 0,
113            deprecated_fns: std::collections::HashMap::new(),
114            imported_names: None,
115        }
116    }
117
118    /// Attach the set of names statically introduced by cross-module imports.
119    ///
120    /// Enables strict cross-module undefined-call errors: call sites that are
121    /// not builtins, not local declarations, not struct constructors, not
122    /// callable variables, and not in `imported` will produce a type error.
123    ///
124    /// Passing `None` (the default) preserves pre-v0.7.12 behavior where
125    /// unresolved call names only surface via lint diagnostics. Callers
126    /// should only pass `Some(set)` when every import in the file resolved
127    /// — see `harn_modules::ModuleGraph::imported_names_for_file`.
128    pub fn with_imported_names(mut self, imported: HashSet<String>) -> Self {
129        self.imported_names = Some(imported);
130        self
131    }
132
133    /// Check a program with source text for autofix generation.
134    pub fn check_with_source(mut self, program: &[SNode], source: &str) -> Vec<TypeDiagnostic> {
135        self.source = Some(source.to_string());
136        self.check_inner(program).0
137    }
138
139    /// Check a program with strict types mode and source text.
140    pub fn check_strict_with_source(
141        mut self,
142        program: &[SNode],
143        source: &str,
144    ) -> Vec<TypeDiagnostic> {
145        self.source = Some(source.to_string());
146        self.check_inner(program).0
147    }
148
149    /// Check a program and return diagnostics.
150    pub fn check(self, program: &[SNode]) -> Vec<TypeDiagnostic> {
151        self.check_inner(program).0
152    }
153
154    /// Check whether a function call value is a boundary source that produces
155    /// unvalidated data.  Returns `None` if the value is type-safe
156    /// (e.g. llm_call with a schema option, or a non-boundary function).
157    pub(in crate::typechecker) fn detect_boundary_source(
158        value: &SNode,
159        scope: &TypeScope,
160    ) -> Option<String> {
161        match &value.node {
162            Node::FunctionCall { name, args } => {
163                if !builtin_signatures::is_untyped_boundary_source(name) {
164                    return None;
165                }
166                // llm_call/llm_completion with a schema option are type-safe
167                if (name == "llm_call" || name == "llm_completion")
168                    && Self::llm_call_has_typed_schema_option(args, scope)
169                {
170                    return None;
171                }
172                Some(name.clone())
173            }
174            Node::Identifier(name) => scope.is_untyped_source(name).map(|s| s.to_string()),
175            _ => None,
176        }
177    }
178
179    /// True if an `llm_call` / `llm_completion` options dict names a
180    /// resolvable output schema. Used by the strict-types boundary checks
181    /// to suppress "unvalidated" warnings when the call site is typed.
182    /// Actual return-type narrowing is driven by the generic-builtin
183    /// dispatch path in `infer_type`, not this helper.
184    pub(in crate::typechecker) fn llm_call_has_typed_schema_option(
185        args: &[SNode],
186        scope: &TypeScope,
187    ) -> bool {
188        let Some(opts) = args.get(2) else {
189            return false;
190        };
191        let Node::DictLiteral(entries) = &opts.node else {
192            return false;
193        };
194        entries.iter().any(|entry| {
195            let key = match &entry.key.node {
196                Node::StringLiteral(k) | Node::Identifier(k) => k.as_str(),
197                _ => return false,
198            };
199            (key == "schema" || key == "output_schema")
200                && schema_type_expr_from_node(&entry.value, scope).is_some()
201        })
202    }
203
204    /// Check whether a type annotation is a concrete shape/struct type
205    /// (as opposed to bare `dict` or no annotation).
206    pub(in crate::typechecker) fn is_concrete_type(ty: &TypeExpr) -> bool {
207        matches!(
208            ty,
209            TypeExpr::Shape(_)
210                | TypeExpr::Applied { .. }
211                | TypeExpr::FnType { .. }
212                | TypeExpr::List(_)
213                | TypeExpr::Iter(_)
214                | TypeExpr::DictType(_, _)
215        ) || matches!(ty, TypeExpr::Named(n) if n != "dict" && n != "any" && n != "_")
216    }
217
218    /// Check a program and return both diagnostics and inlay hints.
219    pub fn check_with_hints(
220        mut self,
221        program: &[SNode],
222        source: &str,
223    ) -> (Vec<TypeDiagnostic>, Vec<InlayHintInfo>) {
224        self.source = Some(source.to_string());
225        self.check_inner(program)
226    }
227
228    pub(in crate::typechecker) fn error_at(&mut self, message: String, span: Span) {
229        self.diagnostics.push(TypeDiagnostic {
230            message,
231            severity: DiagnosticSeverity::Error,
232            span: Some(span),
233            help: None,
234            fix: None,
235        });
236    }
237
238    #[allow(dead_code)]
239    pub(in crate::typechecker) fn error_at_with_help(
240        &mut self,
241        message: String,
242        span: Span,
243        help: String,
244    ) {
245        self.diagnostics.push(TypeDiagnostic {
246            message,
247            severity: DiagnosticSeverity::Error,
248            span: Some(span),
249            help: Some(help),
250            fix: None,
251        });
252    }
253
254    pub(in crate::typechecker) fn error_at_with_fix(
255        &mut self,
256        message: String,
257        span: Span,
258        fix: Vec<FixEdit>,
259    ) {
260        self.diagnostics.push(TypeDiagnostic {
261            message,
262            severity: DiagnosticSeverity::Error,
263            span: Some(span),
264            help: None,
265            fix: Some(fix),
266        });
267    }
268
269    pub(in crate::typechecker) fn warning_at(&mut self, message: String, span: Span) {
270        self.diagnostics.push(TypeDiagnostic {
271            message,
272            severity: DiagnosticSeverity::Warning,
273            span: Some(span),
274            help: None,
275            fix: None,
276        });
277    }
278
279    #[allow(dead_code)]
280    pub(in crate::typechecker) fn warning_at_with_help(
281        &mut self,
282        message: String,
283        span: Span,
284        help: String,
285    ) {
286        self.diagnostics.push(TypeDiagnostic {
287            message,
288            severity: DiagnosticSeverity::Warning,
289            span: Some(span),
290            help: Some(help),
291            fix: None,
292        });
293    }
294}
295
296impl Default for TypeChecker {
297    fn default() -> Self {
298        Self::new()
299    }
300}
301
302#[cfg(test)]
303mod tests;