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