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, 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/// Whether this parameter must carry an explicit type.
75///
76/// A default value does not exempt a parameter: `options = nil` still leaves
77/// every other shape a caller may pass unchecked. `self` in an `impl` or
78/// `interface` method is exempt because the enclosing header already names its
79/// type.
80pub fn requires_annotation(kind: DeclarationKind, index: usize, param: &TypedParam) -> bool {
81    if param.type_expr.is_some() {
82        return false;
83    }
84    !is_self_receiver(kind, index, param)
85}
86
87fn is_self_receiver(kind: DeclarationKind, index: usize, param: &TypedParam) -> bool {
88    matches!(
89        kind,
90        DeclarationKind::Method | DeclarationKind::InterfaceMethod
91    ) && index == 0
92        && param.name == "self"
93}
94
95/// Visit every parameter in `program` that the rule requires a type for,
96/// including declarations nested inside another body.
97///
98/// The walk is pre-order, so an `impl` block is always seen before the methods
99/// it owns. That is what lets one pass tell a method apart from a free
100/// function without keeping a parallel copy of the AST's child structure.
101pub fn walk_unannotated_params(program: &[SNode], visit: &mut impl FnMut(UnannotatedParam)) {
102    let mut method_spans: std::collections::HashSet<(usize, usize)> =
103        std::collections::HashSet::new();
104    crate::visit::walk_program(program, &mut |node| match &node.node {
105        Node::ImplBlock { methods, .. } => {
106            for method in methods {
107                method_spans.insert((method.span.start, method.span.end));
108            }
109        }
110        Node::InterfaceDecl { methods, .. } => {
111            for method in methods {
112                report(
113                    DeclarationKind::InterfaceMethod,
114                    &method.name,
115                    method.span,
116                    &method.params,
117                    visit,
118                );
119            }
120        }
121        Node::FnDecl {
122            name,
123            params,
124            is_stream,
125            ..
126        } => {
127            let kind = if method_spans.contains(&(node.span.start, node.span.end)) {
128                DeclarationKind::Method
129            } else if *is_stream {
130                DeclarationKind::Generator
131            } else {
132                DeclarationKind::Function
133            };
134            report(kind, name, node.span, params, visit);
135        }
136        Node::Pipeline { name, params, .. } => {
137            report(DeclarationKind::Pipeline, name, node.span, params, visit);
138        }
139        Node::ToolDecl { name, params, .. } => {
140            report(DeclarationKind::Tool, name, node.span, params, visit);
141        }
142        _ => {}
143    });
144}
145
146/// Collect the same list [`walk_unannotated_params`] visits.
147pub fn unannotated_params(program: &[SNode]) -> Vec<UnannotatedParam> {
148    let mut found = Vec::new();
149    walk_unannotated_params(program, &mut |param| found.push(param));
150    found
151}
152
153fn report(
154    kind: DeclarationKind,
155    owner: &str,
156    owner_span: Span,
157    params: &[TypedParam],
158    visit: &mut impl FnMut(UnannotatedParam),
159) {
160    for (index, param) in params.iter().enumerate() {
161        if requires_annotation(kind, index, param) {
162            visit(UnannotatedParam {
163                kind,
164                owner: owner.to_string(),
165                owner_span,
166                index,
167                name: param.name.clone(),
168                span: param.span,
169                has_default: param.default_value.is_some(),
170                is_rest: param.rest,
171            });
172        }
173    }
174}
175
176/// Byte offset where `: Type` belongs for this parameter, or `None` when the
177/// span does not contain the parameter name (a synthetic parameter, or a span
178/// that does not line up with `source`).
179///
180/// The annotation goes directly after the name, which is after any `...` and
181/// before any ` = default`. Both writers of an annotation — the checker's
182/// suggested fix and the `harn fix` migration — use this offset, so a
183/// parameter can never be annotated in two different places.
184pub fn annotation_insert_offset(source: &str, param: &UnannotatedParam) -> Option<usize> {
185    let region = source.get(param.span.start..param.span.end)?;
186    let mut from = 0usize;
187    while let Some(relative) = region.get(from..)?.find(&param.name) {
188        let start = from + relative;
189        let end = start + param.name.len();
190        let before_ok = start == 0
191            || !region
192                .get(..start)?
193                .chars()
194                .next_back()
195                .is_some_and(|c| c.is_alphanumeric() || c == '_');
196        let after_ok = !region
197            .get(end..)?
198            .chars()
199            .next()
200            .is_some_and(|c| c.is_alphanumeric() || c == '_');
201        if before_ok && after_ok {
202            return Some(param.span.start + end);
203        }
204        from = start + param.name.chars().next().map_or(1, char::len_utf8);
205    }
206    None
207}
208
209/// The diagnostic sentence for one hit. Shared so the checker and any other
210/// renderer word the rule identically.
211pub fn message(found: &UnannotatedParam) -> String {
212    format!(
213        "{} `{}` parameter `{}` has no type annotation",
214        found.kind.as_str(),
215        found.owner,
216        found.name
217    )
218}
219
220/// The repair sentence for one hit.
221pub fn help(found: &UnannotatedParam) -> String {
222    format!(
223        "annotate the parameter, for example `{name}: string`, or write `{name}: unknown` and \
224         narrow it at the dynamic boundary. `harn fix --apply` infers the type from the body and the \
225         call sites.",
226        name = found.name
227    )
228}