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::{callable_decl_name, 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        importer: &Path,
115        module_path: &Path,
116        member_names: &[String],
117    ) -> BTreeMap<String, NamespaceMemberSignature> {
118        let mut out = BTreeMap::new();
119        for name in member_names {
120            let mut visited = HashSet::new();
121            let sibling_decl = crate::sibling_module_access(importer, module_path)
122                .then(|| self.modules.get(&normalize_path(module_path)))
123                .flatten()
124                .filter(|module| module.sibling_exports.contains(name))
125                .and_then(|module| {
126                    module
127                        .callable_declarations
128                        .iter()
129                        .find(|decl| callable_decl_name(decl) == Some(name.as_str()))
130                        .cloned()
131                });
132            let Some(decl) = sibling_decl
133                .or_else(|| self.find_exported_callable_decl(module_path, name, &mut visited))
134            else {
135                continue;
136            };
137            // Resolve named types against the module that DEFINES the member,
138            // not the re-exporting one: a signature forwarded through a barrel
139            // module names types the barrel never declared.
140            let origin = self
141                .export_definition_of(module_path, name)
142                .map_or_else(|| normalize_path(module_path), |site| site.file);
143            if let Some(signature) = self.lower_callable_signature(&origin, &decl) {
144                out.insert(name.clone(), signature);
145            }
146        }
147        out
148    }
149
150    fn lower_callable_signature(
151        &self,
152        origin: &Path,
153        decl: &SNode,
154    ) -> Option<NamespaceMemberSignature> {
155        let inner = match &decl.node {
156            Node::AttributedDecl { inner, .. } => inner.as_ref(),
157            _ => decl,
158        };
159        let (params, return_type, type_predicate) = match &inner.node {
160            Node::FnDecl {
161                params,
162                return_type,
163                type_predicate,
164                type_params,
165                ..
166            } => {
167                // A generic signature would need the checker's inference to
168                // bind its type parameters; lowering it to a fixed `FnType`
169                // would report a mismatch against an unbound name. Leave
170                // generics on the old gradual path rather than guess.
171                if !type_params.is_empty() {
172                    return None;
173                }
174                (params, return_type, type_predicate.as_ref())
175            }
176            Node::Pipeline {
177                params,
178                return_type,
179                ..
180            } => (params, return_type, None),
181            _ => return None,
182        };
183        // A rest parameter accepts any tail, so a fixed positional `FnType`
184        // would misdescribe it.
185        if params.iter().any(|param| param.rest) {
186            return None;
187        }
188        let lowered: Vec<TypeExpr> = params
189            .iter()
190            .map(|param| self.lower_param_type(origin, param))
191            .collect();
192        let ret = return_type
193            .as_ref()
194            .map(|ty| self.inline_named_types(origin, ty, &mut HashSet::new(), 0))
195            .unwrap_or(TypeExpr::Named("any".into()));
196        let type_predicate = type_predicate.and_then(|predicate| {
197            let type_expr =
198                self.inline_named_types(origin, &predicate.type_expr, &mut HashSet::new(), 0);
199            (!contains_gradual_type(&type_expr)).then(|| TypePredicate {
200                parameter: predicate.parameter.clone(),
201                type_expr,
202                one_sided: predicate.one_sided,
203                span: predicate.span,
204            })
205        });
206        Some(NamespaceMemberSignature {
207            param_names: params.iter().map(|param| param.name.clone()).collect(),
208            required_params: params
209                .iter()
210                .position(|param| param.default_value.is_some())
211                .unwrap_or(params.len()),
212            fn_type: TypeExpr::FnType {
213                params: lowered,
214                return_type: Box::new(ret),
215            },
216            type_predicate,
217        })
218    }
219
220    /// A parameter with a default is optional at the call site. `FnType` has no
221    /// optionality, and `required_params` is derived from its length, so a
222    /// defaulted parameter must not tighten the required count.
223    fn lower_param_type(&self, origin: &Path, param: &TypedParam) -> TypeExpr {
224        let Some(declared) = &param.type_expr else {
225            return TypeExpr::Named("any".into());
226        };
227        if param.default_value.is_some() {
228            return TypeExpr::Named("any".into());
229        }
230        self.inline_named_types(origin, declared, &mut HashSet::new(), 0)
231    }
232
233    /// Replace every module-local named type with its structural body.
234    ///
235    /// A name that cannot be resolved to a plain type alias in `origin` —
236    /// a struct, enum, interface, generic parameter, or a name from a module
237    /// this walk cannot see — becomes `any`. That is deliberate: an
238    /// unresolvable `Named` would be compared structurally against the
239    /// argument and could reject a correct program, and a false positive in
240    /// `harn check` is worse than the gap this closes.
241    fn inline_named_types(
242        &self,
243        origin: &Path,
244        ty: &TypeExpr,
245        visited: &mut HashSet<String>,
246        depth: usize,
247    ) -> TypeExpr {
248        let recurse = |graph: &Self, inner: &TypeExpr, visited: &mut HashSet<String>| {
249            graph.inline_named_types(origin, inner, visited, depth + 1)
250        };
251        match ty {
252            TypeExpr::Named(name) => {
253                if is_builtin_type_name(name) {
254                    return ty.clone();
255                }
256                if depth >= MAX_INLINE_DEPTH || !visited.insert(name.clone()) {
257                    return TypeExpr::Named("any".into());
258                }
259                let resolved = self
260                    .find_exported_type_decl(origin, name, &mut HashSet::new())
261                    .or_else(|| self.local_type_decl(origin, name));
262                let body = match resolved.as_ref().map(|decl| &decl.node) {
263                    Some(Node::TypeDecl {
264                        type_params,
265                        type_expr,
266                        ..
267                    }) if type_params.is_empty() => {
268                        self.inline_named_types(origin, type_expr, visited, depth + 1)
269                    }
270                    _ => TypeExpr::Named("any".into()),
271                };
272                visited.remove(name);
273                body
274            }
275            TypeExpr::Union(items) => TypeExpr::Union(
276                items
277                    .iter()
278                    .map(|item| recurse(self, item, visited))
279                    .collect(),
280            ),
281            TypeExpr::Intersection(items) => TypeExpr::Intersection(
282                items
283                    .iter()
284                    .map(|item| recurse(self, item, visited))
285                    .collect(),
286            ),
287            TypeExpr::Shape(fields) => TypeExpr::Shape(
288                fields
289                    .iter()
290                    .map(|field| {
291                        let mut next = field.clone();
292                        next.type_expr = recurse(self, &field.type_expr, visited);
293                        next
294                    })
295                    .collect(),
296            ),
297            TypeExpr::List(inner) => TypeExpr::List(Box::new(recurse(self, inner, visited))),
298            TypeExpr::Iter(inner) => TypeExpr::Iter(Box::new(recurse(self, inner, visited))),
299            TypeExpr::Owned(inner) => TypeExpr::Owned(Box::new(recurse(self, inner, visited))),
300            TypeExpr::Tuple(items) => TypeExpr::Tuple(
301                items
302                    .iter()
303                    .map(|item| recurse(self, item, visited))
304                    .collect(),
305            ),
306            TypeExpr::DictType(key, value) => TypeExpr::DictType(
307                Box::new(recurse(self, key, visited)),
308                Box::new(recurse(self, value, visited)),
309            ),
310            // An open shape's row tail, a generator/stream payload, an applied
311            // generic, and a function-typed parameter all carry inference
312            // obligations that a structural inline cannot preserve. Leave the
313            // whole parameter gradual rather than lower it wrongly.
314            TypeExpr::OpenShape { .. }
315            | TypeExpr::Generator(_)
316            | TypeExpr::Stream(_)
317            | TypeExpr::Applied { .. }
318            | TypeExpr::FnType { .. } => TypeExpr::Named("any".into()),
319            TypeExpr::Never | TypeExpr::LitString(_) | TypeExpr::LitInt(_) => ty.clone(),
320        }
321    }
322
323    fn local_type_decl(&self, module_path: &Path, name: &str) -> Option<SNode> {
324        let module = self
325            .modules
326            .get(module_path)
327            .or_else(|| self.modules.get(&normalize_path(module_path)))?;
328        module
329            .type_declarations
330            .iter()
331            .find(|decl| crate::type_decl_name(decl) == Some(name))
332            .cloned()
333    }
334}