Skip to main content

harn_modules/
namespace_signatures.rs

1//! Exported call signatures for `import * as alias from "..."` members.
2//!
3//! A namespace member used to reach the type checker as `any`, so
4//! `alias.member(...)` was the one call form nothing checked: not its argument
5//! types, not its required arity. The same call written as a named import was
6//! checked normally, and the gap was identical for a local module and a
7//! package — the import *form* was the variable, not the boundary (#6172).
8//!
9//! Signatures are lowered to a self-contained [`TypeExpr::FnType`] here rather
10//! than handed over as declarations, because a namespace import deliberately
11//! does not flatten the target's type names into the consumer. Every named
12//! type in a parameter position is resolved against the *defining* module and
13//! inlined structurally, so the consumer never has to have `Request` in scope
14//! and a consumer type of the same name cannot collide with it.
15
16use std::collections::{BTreeMap, HashSet};
17use std::path::Path;
18
19use harn_parser::{Node, SNode, TypeExpr, TypePredicate, TypedParam};
20
21use crate::{normalize_path, ModuleGraph};
22
23/// Depth cap for inlining a named type into a parameter position.
24///
25/// A mutually recursive alias pair (`A = {next: B}`, `B = {next: A}`) has no
26/// finite structural expansion. The visited set already breaks a direct cycle;
27/// this bounds the pathological indirect case so lowering always terminates.
28const MAX_INLINE_DEPTH: usize = 16;
29
30/// One namespace member's lowered call signature.
31///
32/// `param_names` travels beside the `FnType` because `TypeExpr::FnType` is
33/// positional only. Without it a mismatch reports `argument 2 \`arg2\``, while
34/// the same call through a named import reports `argument 2 \`request\`` — the
35/// name is what tells an author which parameter the signature change moved.
36#[derive(Debug, Clone, PartialEq)]
37pub struct NamespaceMemberSignature {
38    pub param_names: Vec<String>,
39    /// Arguments that must be supplied. Mirrors the checker's own rule for a
40    /// declared `fn`: everything up to the first parameter with a default.
41    /// Deriving it from the parameter count instead would reject every
42    /// legitimate call that omits a defaulted tail.
43    pub required_params: usize,
44    pub fn_type: TypeExpr,
45    /// Caller-side narrowing contract with module-local types inlined.
46    pub type_predicate: Option<TypePredicate>,
47}
48
49/// Names the checker resolves on its own. Inlining must not rewrite these.
50fn is_builtin_type_name(name: &str) -> bool {
51    matches!(
52        name,
53        "int"
54            | "float"
55            | "string"
56            | "bool"
57            | "nil"
58            | "list"
59            | "dict"
60            | "set"
61            | "closure"
62            | "bytes"
63            | "any"
64            | "unknown"
65            | "never"
66            | "number"
67            | "Harness"
68            | "_"
69    )
70}
71
72fn contains_gradual_type(ty: &TypeExpr) -> bool {
73    match ty {
74        TypeExpr::Named(name) => matches!(name.as_str(), "any" | "unknown" | "_"),
75        TypeExpr::Union(items) | TypeExpr::Intersection(items) | TypeExpr::Tuple(items) => {
76            items.iter().any(contains_gradual_type)
77        }
78        TypeExpr::Shape(fields) => fields
79            .iter()
80            .any(|field| contains_gradual_type(&field.type_expr)),
81        TypeExpr::OpenShape { fields, rests } => {
82            fields
83                .iter()
84                .any(|field| contains_gradual_type(&field.type_expr))
85                || rests.iter().any(contains_gradual_type)
86        }
87        TypeExpr::List(inner)
88        | TypeExpr::Iter(inner)
89        | TypeExpr::Generator(inner)
90        | TypeExpr::Stream(inner)
91        | TypeExpr::Owned(inner) => contains_gradual_type(inner),
92        TypeExpr::DictType(key, value) => {
93            contains_gradual_type(key) || contains_gradual_type(value)
94        }
95        TypeExpr::Applied { args, .. } => args.iter().any(contains_gradual_type),
96        TypeExpr::FnType {
97            params,
98            return_type,
99        } => params.iter().any(contains_gradual_type) || contains_gradual_type(return_type),
100        TypeExpr::Never | TypeExpr::LitString(_) | TypeExpr::LitInt(_) => false,
101    }
102}
103
104impl ModuleGraph {
105    /// Exported call signatures for the members of one namespace import.
106    ///
107    /// Only `fn` and `pipeline` members get a signature; a `tool` has no
108    /// statically checkable parameter list, and a non-callable export is not a
109    /// call target. A member with no entry keeps its previous `any` treatment,
110    /// which is what keeps this change incapable of rejecting a program the
111    /// checker used to accept for reasons it cannot actually see.
112    pub(crate) fn namespace_member_signatures(
113        &self,
114        module_path: &Path,
115        member_names: &[String],
116    ) -> BTreeMap<String, NamespaceMemberSignature> {
117        let mut out = BTreeMap::new();
118        for name in member_names {
119            let mut visited = HashSet::new();
120            let Some(decl) = self.find_exported_callable_decl(module_path, name, &mut visited)
121            else {
122                continue;
123            };
124            // Resolve named types against the module that DEFINES the member,
125            // not the re-exporting one: a signature forwarded through a barrel
126            // module names types the barrel never declared.
127            let origin = self
128                .export_definition_of(module_path, name)
129                .map_or_else(|| normalize_path(module_path), |site| site.file);
130            if let Some(signature) = self.lower_callable_signature(&origin, &decl) {
131                out.insert(name.clone(), signature);
132            }
133        }
134        out
135    }
136
137    fn lower_callable_signature(
138        &self,
139        origin: &Path,
140        decl: &SNode,
141    ) -> Option<NamespaceMemberSignature> {
142        let inner = match &decl.node {
143            Node::AttributedDecl { inner, .. } => inner.as_ref(),
144            _ => decl,
145        };
146        let (params, return_type, type_predicate) = match &inner.node {
147            Node::FnDecl {
148                params,
149                return_type,
150                type_predicate,
151                type_params,
152                ..
153            } => {
154                // A generic signature would need the checker's inference to
155                // bind its type parameters; lowering it to a fixed `FnType`
156                // would report a mismatch against an unbound name. Leave
157                // generics on the old gradual path rather than guess.
158                if !type_params.is_empty() {
159                    return None;
160                }
161                (params, return_type, type_predicate.as_ref())
162            }
163            Node::Pipeline {
164                params,
165                return_type,
166                ..
167            } => (params, return_type, None),
168            _ => return None,
169        };
170        // A rest parameter accepts any tail, so a fixed positional `FnType`
171        // would misdescribe it.
172        if params.iter().any(|param| param.rest) {
173            return None;
174        }
175        let lowered: Vec<TypeExpr> = params
176            .iter()
177            .map(|param| self.lower_param_type(origin, param))
178            .collect();
179        let ret = return_type
180            .as_ref()
181            .map(|ty| self.inline_named_types(origin, ty, &mut HashSet::new(), 0))
182            .unwrap_or(TypeExpr::Named("any".into()));
183        let type_predicate = type_predicate.and_then(|predicate| {
184            let type_expr =
185                self.inline_named_types(origin, &predicate.type_expr, &mut HashSet::new(), 0);
186            (!contains_gradual_type(&type_expr)).then(|| TypePredicate {
187                parameter: predicate.parameter.clone(),
188                type_expr,
189                one_sided: predicate.one_sided,
190                span: predicate.span,
191            })
192        });
193        Some(NamespaceMemberSignature {
194            param_names: params.iter().map(|param| param.name.clone()).collect(),
195            required_params: params
196                .iter()
197                .position(|param| param.default_value.is_some())
198                .unwrap_or(params.len()),
199            fn_type: TypeExpr::FnType {
200                params: lowered,
201                return_type: Box::new(ret),
202            },
203            type_predicate,
204        })
205    }
206
207    /// A parameter with a default is optional at the call site. `FnType` has no
208    /// optionality, and `required_params` is derived from its length, so a
209    /// defaulted parameter must not tighten the required count.
210    fn lower_param_type(&self, origin: &Path, param: &TypedParam) -> TypeExpr {
211        let Some(declared) = &param.type_expr else {
212            return TypeExpr::Named("any".into());
213        };
214        if param.default_value.is_some() {
215            return TypeExpr::Named("any".into());
216        }
217        self.inline_named_types(origin, declared, &mut HashSet::new(), 0)
218    }
219
220    /// Replace every module-local named type with its structural body.
221    ///
222    /// A name that cannot be resolved to a plain type alias in `origin` —
223    /// a struct, enum, interface, generic parameter, or a name from a module
224    /// this walk cannot see — becomes `any`. That is deliberate: an
225    /// unresolvable `Named` would be compared structurally against the
226    /// argument and could reject a correct program, and a false positive in
227    /// `harn check` is worse than the gap this closes.
228    fn inline_named_types(
229        &self,
230        origin: &Path,
231        ty: &TypeExpr,
232        visited: &mut HashSet<String>,
233        depth: usize,
234    ) -> TypeExpr {
235        let recurse = |graph: &Self, inner: &TypeExpr, visited: &mut HashSet<String>| {
236            graph.inline_named_types(origin, inner, visited, depth + 1)
237        };
238        match ty {
239            TypeExpr::Named(name) => {
240                if is_builtin_type_name(name) {
241                    return ty.clone();
242                }
243                if depth >= MAX_INLINE_DEPTH || !visited.insert(name.clone()) {
244                    return TypeExpr::Named("any".into());
245                }
246                let resolved = self
247                    .find_exported_type_decl(origin, name, &mut HashSet::new())
248                    .or_else(|| self.local_type_decl(origin, name));
249                let body = match resolved.as_ref().map(|decl| &decl.node) {
250                    Some(Node::TypeDecl {
251                        type_params,
252                        type_expr,
253                        ..
254                    }) if type_params.is_empty() => {
255                        self.inline_named_types(origin, type_expr, visited, depth + 1)
256                    }
257                    _ => TypeExpr::Named("any".into()),
258                };
259                visited.remove(name);
260                body
261            }
262            TypeExpr::Union(items) => TypeExpr::Union(
263                items
264                    .iter()
265                    .map(|item| recurse(self, item, visited))
266                    .collect(),
267            ),
268            TypeExpr::Intersection(items) => TypeExpr::Intersection(
269                items
270                    .iter()
271                    .map(|item| recurse(self, item, visited))
272                    .collect(),
273            ),
274            TypeExpr::Shape(fields) => TypeExpr::Shape(
275                fields
276                    .iter()
277                    .map(|field| {
278                        let mut next = field.clone();
279                        next.type_expr = recurse(self, &field.type_expr, visited);
280                        next
281                    })
282                    .collect(),
283            ),
284            TypeExpr::List(inner) => TypeExpr::List(Box::new(recurse(self, inner, visited))),
285            TypeExpr::Iter(inner) => TypeExpr::Iter(Box::new(recurse(self, inner, visited))),
286            TypeExpr::Owned(inner) => TypeExpr::Owned(Box::new(recurse(self, inner, visited))),
287            TypeExpr::Tuple(items) => TypeExpr::Tuple(
288                items
289                    .iter()
290                    .map(|item| recurse(self, item, visited))
291                    .collect(),
292            ),
293            TypeExpr::DictType(key, value) => TypeExpr::DictType(
294                Box::new(recurse(self, key, visited)),
295                Box::new(recurse(self, value, visited)),
296            ),
297            // An open shape's row tail, a generator/stream payload, an applied
298            // generic, and a function-typed parameter all carry inference
299            // obligations that a structural inline cannot preserve. Leave the
300            // whole parameter gradual rather than lower it wrongly.
301            TypeExpr::OpenShape { .. }
302            | TypeExpr::Generator(_)
303            | TypeExpr::Stream(_)
304            | TypeExpr::Applied { .. }
305            | TypeExpr::FnType { .. } => TypeExpr::Named("any".into()),
306            TypeExpr::Never | TypeExpr::LitString(_) | TypeExpr::LitInt(_) => ty.clone(),
307        }
308    }
309
310    fn local_type_decl(&self, module_path: &Path, name: &str) -> Option<SNode> {
311        let module = self
312            .modules
313            .get(module_path)
314            .or_else(|| self.modules.get(&normalize_path(module_path)))?;
315        module
316            .type_declarations
317            .iter()
318            .find(|decl| crate::type_decl_name(decl) == Some(name))
319            .cloned()
320    }
321}