Skip to main content

harn_parser/
param_annotations.rs

1//! The one place that decides whether a parameter must carry a type.
2//!
3//! A parameter with no annotation used to mean `any`, which switches type
4//! checking off in both directions: the body may reach for any member, and
5//! callers may pass anything. Nothing recovers that type later, so the
6//! declaration is a hole rather than a gradual step.
7//!
8//! Every consumer of that judgement reads it here. The type checker turns a
9//! hit into `HARN-TYP-028`, and the `harn fix` annotation repair walks the
10//! same list to decide which sites it must fill in. A second predicate
11//! elsewhere would let the error and the migration disagree about what needs
12//! a type, so there is exactly one.
13
14use crate::ast::{Node, SNode, TypeExpr, TypedParam};
15use harn_lexer::Span;
16
17/// The declaration form that owns a parameter list.
18///
19/// Only forms whose parameter types nothing can recover appear here. A closure
20/// or lambda parameter is deliberately absent: the checker types those from the
21/// expected type at the position where the literal appears.
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum DeclarationKind {
24    /// `fn name(...)`, with or without `pub`.
25    Function,
26    /// `gen fn name(...)`.
27    Generator,
28    /// `pipeline name(...)`.
29    Pipeline,
30    /// `tool name(...)`.
31    Tool,
32    /// `fn name(...)` inside an `impl` block.
33    Method,
34    /// A method signature inside an `interface` block.
35    InterfaceMethod,
36}
37
38impl DeclarationKind {
39    /// Product vocabulary for the diagnostic message.
40    pub const fn as_str(self) -> &'static str {
41        match self {
42            DeclarationKind::Function => "function",
43            DeclarationKind::Generator => "generator",
44            DeclarationKind::Pipeline => "pipeline",
45            DeclarationKind::Tool => "tool",
46            DeclarationKind::Method => "method",
47            DeclarationKind::InterfaceMethod => "interface method",
48        }
49    }
50}
51
52/// One declared parameter that the rule requires an annotation for.
53///
54/// Owned rather than borrowed so a caller can hold the list while it rewrites
55/// the file the parameters came from.
56#[derive(Debug, Clone)]
57pub struct UnannotatedParam {
58    pub kind: DeclarationKind,
59    /// Name of the declaration that owns the parameter list.
60    pub owner: String,
61    /// Source extent of the owning declaration. A repair that has to blame a
62    /// later type error on one of these parameters uses it to find them.
63    pub owner_span: Span,
64    /// Position in the declared parameter list, counting any `self`.
65    pub index: usize,
66    pub name: String,
67    /// Source extent of the parameter, from an optional `...` through any
68    /// default value.
69    pub span: Span,
70    pub has_default: bool,
71    pub is_rest: bool,
72}
73
74/// One parameter from a declaration form governed by this module.
75///
76/// Migration audits use the complete ordered list before and after a rewrite.
77/// Keeping that projection beside [`requires_annotation`] prevents a fixer
78/// from rebuilding the declaration walk and silently missing a form the
79/// checker rejects.
80#[derive(Debug, Clone)]
81pub struct DeclaredParam {
82    pub kind: DeclarationKind,
83    pub owner: String,
84    pub index: usize,
85    pub name: String,
86    pub type_expr: Option<TypeExpr>,
87    /// The checker-owned decision for this parameter in the source as parsed.
88    pub requires_annotation: bool,
89    owner_span: Span,
90    span: Span,
91    has_default: bool,
92    is_rest: bool,
93}
94
95/// Whether this parameter must carry an explicit type.
96///
97/// A default value does not exempt a parameter: `options = nil` still leaves
98/// every other shape a caller may pass unchecked. `self` in an `impl` or
99/// `interface` method is exempt because the enclosing header already names its
100/// type.
101pub fn requires_annotation(kind: DeclarationKind, index: usize, param: &TypedParam) -> bool {
102    if param.type_expr.is_some() {
103        return false;
104    }
105    !is_self_receiver(kind, index, param)
106}
107
108fn is_self_receiver(kind: DeclarationKind, index: usize, param: &TypedParam) -> bool {
109    matches!(
110        kind,
111        DeclarationKind::Method | DeclarationKind::InterfaceMethod
112    ) && index == 0
113        && param.name == "self"
114}
115
116/// Visit every parameter in `program` that the rule requires a type for,
117/// including declarations nested inside another body.
118///
119/// The walk is pre-order, so an `impl` block is always seen before the methods
120/// it owns. That is what lets one pass tell a method apart from a free
121/// function without keeping a parallel copy of the AST's child structure.
122pub fn walk_unannotated_params(program: &[SNode], visit: &mut impl FnMut(UnannotatedParam)) {
123    walk_declared_params(program, &mut |param| {
124        if !param.requires_annotation {
125            return;
126        }
127        visit(UnannotatedParam {
128            kind: param.kind,
129            owner: param.owner,
130            owner_span: param.owner_span,
131            index: param.index,
132            name: param.name,
133            span: param.span,
134            has_default: param.has_default,
135            is_rest: param.is_rest,
136        });
137    });
138}
139
140/// Visit every declared parameter governed by the explicit-annotation rule.
141///
142/// The order is stable across annotation-only rewrites, so migration code can
143/// audit the corresponding post-rewrite parameter without inventing a second
144/// declaration identity scheme.
145pub fn walk_declared_params(program: &[SNode], visit: &mut impl FnMut(DeclaredParam)) {
146    let mut method_spans: std::collections::HashSet<(usize, usize)> =
147        std::collections::HashSet::new();
148    crate::visit::walk_program(program, &mut |node| match &node.node {
149        Node::ImplBlock { methods, .. } => {
150            for method in methods {
151                method_spans.insert((method.span.start, method.span.end));
152            }
153        }
154        Node::InterfaceDecl { methods, .. } => {
155            for method in methods {
156                report_declared(
157                    DeclarationKind::InterfaceMethod,
158                    &method.name,
159                    method.span,
160                    &method.params,
161                    visit,
162                );
163            }
164        }
165        Node::FnDecl {
166            name,
167            params,
168            is_stream,
169            ..
170        } => {
171            let kind = if method_spans.contains(&(node.span.start, node.span.end)) {
172                DeclarationKind::Method
173            } else if *is_stream {
174                DeclarationKind::Generator
175            } else {
176                DeclarationKind::Function
177            };
178            report_declared(kind, name, node.span, params, visit);
179        }
180        Node::Pipeline { name, params, .. } => {
181            report_declared(DeclarationKind::Pipeline, name, node.span, params, visit);
182        }
183        Node::ToolDecl { name, params, .. } => {
184            report_declared(DeclarationKind::Tool, name, node.span, params, visit);
185        }
186        _ => {}
187    });
188}
189
190/// Collect the complete ordered parameter projection used by migration audits.
191pub fn declared_params(program: &[SNode]) -> Vec<DeclaredParam> {
192    let mut found = Vec::new();
193    walk_declared_params(program, &mut |param| found.push(param));
194    found
195}
196
197/// Collect the same list [`walk_unannotated_params`] visits.
198pub fn unannotated_params(program: &[SNode]) -> Vec<UnannotatedParam> {
199    let mut found = Vec::new();
200    walk_unannotated_params(program, &mut |param| found.push(param));
201    found
202}
203
204fn report_declared(
205    kind: DeclarationKind,
206    owner: &str,
207    owner_span: Span,
208    params: &[TypedParam],
209    visit: &mut impl FnMut(DeclaredParam),
210) {
211    for (index, param) in params.iter().enumerate() {
212        visit(DeclaredParam {
213            kind,
214            owner: owner.to_string(),
215            index,
216            name: param.name.clone(),
217            type_expr: param.type_expr.clone(),
218            requires_annotation: requires_annotation(kind, index, param),
219            owner_span,
220            span: param.span,
221            has_default: param.default_value.is_some(),
222            is_rest: param.rest,
223        });
224    }
225}
226
227/// Byte offset where `: Type` belongs for this parameter, or `None` when the
228/// span does not contain the parameter name (a synthetic parameter, or a span
229/// that does not line up with `source`).
230///
231/// The annotation goes directly after the name, which is after any `...` and
232/// before any ` = default`. Both writers of an annotation — the checker's
233/// suggested fix and the `harn fix` migration — use this offset, so a
234/// parameter can never be annotated in two different places.
235pub fn annotation_insert_offset(source: &str, param: &UnannotatedParam) -> Option<usize> {
236    let region = source.get(param.span.start..param.span.end)?;
237    let mut from = 0usize;
238    while let Some(relative) = region.get(from..)?.find(&param.name) {
239        let start = from + relative;
240        let end = start + param.name.len();
241        let before_ok = start == 0
242            || !region
243                .get(..start)?
244                .chars()
245                .next_back()
246                .is_some_and(|c| c.is_alphanumeric() || c == '_');
247        let after_ok = !region
248            .get(end..)?
249            .chars()
250            .next()
251            .is_some_and(|c| c.is_alphanumeric() || c == '_');
252        if before_ok && after_ok {
253            return Some(param.span.start + end);
254        }
255        from = start + param.name.chars().next().map_or(1, char::len_utf8);
256    }
257    None
258}
259
260/// The diagnostic sentence for one hit. Shared so the checker and any other
261/// renderer word the rule identically.
262pub fn message(found: &UnannotatedParam) -> String {
263    format!(
264        "{} `{}` parameter `{}` has no type annotation",
265        found.kind.as_str(),
266        found.owner,
267        found.name
268    )
269}
270
271/// The repair sentence for one hit.
272pub fn help(found: &UnannotatedParam) -> String {
273    format!(
274        "annotate the parameter, for example `{name}: string`, or write `{name}: unknown` and \
275         narrow it at the dynamic boundary. `harn fix --apply` infers the type from the body and the \
276         call sites.",
277        name = found.name
278    )
279}