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, 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}
46
47/// Names the checker resolves on its own. Inlining must not rewrite these.
48fn is_builtin_type_name(name: &str) -> bool {
49    matches!(
50        name,
51        "int"
52            | "float"
53            | "string"
54            | "bool"
55            | "nil"
56            | "list"
57            | "dict"
58            | "set"
59            | "closure"
60            | "bytes"
61            | "any"
62            | "unknown"
63            | "never"
64            | "number"
65            | "Harness"
66            | "_"
67    )
68}
69
70impl ModuleGraph {
71    /// Exported call signatures for the members of one namespace import.
72    ///
73    /// Only `fn` and `pipeline` members get a signature; a `tool` has no
74    /// statically checkable parameter list, and a non-callable export is not a
75    /// call target. A member with no entry keeps its previous `any` treatment,
76    /// which is what keeps this change incapable of rejecting a program the
77    /// checker used to accept for reasons it cannot actually see.
78    pub(crate) fn namespace_member_signatures(
79        &self,
80        module_path: &Path,
81        member_names: &[String],
82    ) -> BTreeMap<String, NamespaceMemberSignature> {
83        let mut out = BTreeMap::new();
84        for name in member_names {
85            let mut visited = HashSet::new();
86            let Some(decl) = self.find_exported_callable_decl(module_path, name, &mut visited)
87            else {
88                continue;
89            };
90            // Resolve named types against the module that DEFINES the member,
91            // not the re-exporting one: a signature forwarded through a barrel
92            // module names types the barrel never declared.
93            let origin = self
94                .export_definition_of(module_path, name)
95                .map_or_else(|| normalize_path(module_path), |site| site.file);
96            if let Some(signature) = self.lower_callable_signature(&origin, &decl) {
97                out.insert(name.clone(), signature);
98            }
99        }
100        out
101    }
102
103    fn lower_callable_signature(
104        &self,
105        origin: &Path,
106        decl: &SNode,
107    ) -> Option<NamespaceMemberSignature> {
108        let inner = match &decl.node {
109            Node::AttributedDecl { inner, .. } => inner.as_ref(),
110            _ => decl,
111        };
112        let (params, return_type) = match &inner.node {
113            Node::FnDecl {
114                params,
115                return_type,
116                type_params,
117                ..
118            } => {
119                // A generic signature would need the checker's inference to
120                // bind its type parameters; lowering it to a fixed `FnType`
121                // would report a mismatch against an unbound name. Leave
122                // generics on the old gradual path rather than guess.
123                if !type_params.is_empty() {
124                    return None;
125                }
126                (params, return_type)
127            }
128            Node::Pipeline {
129                params,
130                return_type,
131                ..
132            } => (params, return_type),
133            _ => return None,
134        };
135        // A rest parameter accepts any tail, so a fixed positional `FnType`
136        // would misdescribe it.
137        if params.iter().any(|param| param.rest) {
138            return None;
139        }
140        let lowered: Vec<TypeExpr> = params
141            .iter()
142            .map(|param| self.lower_param_type(origin, param))
143            .collect();
144        let ret = return_type
145            .as_ref()
146            .map(|ty| self.inline_named_types(origin, ty, &mut HashSet::new(), 0))
147            .unwrap_or(TypeExpr::Named("any".into()));
148        Some(NamespaceMemberSignature {
149            param_names: params.iter().map(|param| param.name.clone()).collect(),
150            required_params: params
151                .iter()
152                .position(|param| param.default_value.is_some())
153                .unwrap_or(params.len()),
154            fn_type: TypeExpr::FnType {
155                params: lowered,
156                return_type: Box::new(ret),
157            },
158        })
159    }
160
161    /// A parameter with a default is optional at the call site. `FnType` has no
162    /// optionality, and `required_params` is derived from its length, so a
163    /// defaulted parameter must not tighten the required count.
164    fn lower_param_type(&self, origin: &Path, param: &TypedParam) -> TypeExpr {
165        let Some(declared) = &param.type_expr else {
166            return TypeExpr::Named("any".into());
167        };
168        if param.default_value.is_some() {
169            return TypeExpr::Named("any".into());
170        }
171        self.inline_named_types(origin, declared, &mut HashSet::new(), 0)
172    }
173
174    /// Replace every module-local named type with its structural body.
175    ///
176    /// A name that cannot be resolved to a plain type alias in `origin` —
177    /// a struct, enum, interface, generic parameter, or a name from a module
178    /// this walk cannot see — becomes `any`. That is deliberate: an
179    /// unresolvable `Named` would be compared structurally against the
180    /// argument and could reject a correct program, and a false positive in
181    /// `harn check` is worse than the gap this closes.
182    fn inline_named_types(
183        &self,
184        origin: &Path,
185        ty: &TypeExpr,
186        visited: &mut HashSet<String>,
187        depth: usize,
188    ) -> TypeExpr {
189        let recurse = |graph: &Self, inner: &TypeExpr, visited: &mut HashSet<String>| {
190            graph.inline_named_types(origin, inner, visited, depth + 1)
191        };
192        match ty {
193            TypeExpr::Named(name) => {
194                if is_builtin_type_name(name) {
195                    return ty.clone();
196                }
197                if depth >= MAX_INLINE_DEPTH || !visited.insert(name.clone()) {
198                    return TypeExpr::Named("any".into());
199                }
200                let resolved = self
201                    .find_exported_type_decl(origin, name, &mut HashSet::new())
202                    .or_else(|| self.local_type_decl(origin, name));
203                let body = match resolved.as_ref().map(|decl| &decl.node) {
204                    Some(Node::TypeDecl {
205                        type_params,
206                        type_expr,
207                        ..
208                    }) if type_params.is_empty() => {
209                        self.inline_named_types(origin, type_expr, visited, depth + 1)
210                    }
211                    _ => TypeExpr::Named("any".into()),
212                };
213                visited.remove(name);
214                body
215            }
216            TypeExpr::Union(items) => TypeExpr::Union(
217                items
218                    .iter()
219                    .map(|item| recurse(self, item, visited))
220                    .collect(),
221            ),
222            TypeExpr::Intersection(items) => TypeExpr::Intersection(
223                items
224                    .iter()
225                    .map(|item| recurse(self, item, visited))
226                    .collect(),
227            ),
228            TypeExpr::Shape(fields) => TypeExpr::Shape(
229                fields
230                    .iter()
231                    .map(|field| {
232                        let mut next = field.clone();
233                        next.type_expr = recurse(self, &field.type_expr, visited);
234                        next
235                    })
236                    .collect(),
237            ),
238            TypeExpr::List(inner) => TypeExpr::List(Box::new(recurse(self, inner, visited))),
239            TypeExpr::Iter(inner) => TypeExpr::Iter(Box::new(recurse(self, inner, visited))),
240            TypeExpr::Owned(inner) => TypeExpr::Owned(Box::new(recurse(self, inner, visited))),
241            TypeExpr::Tuple(items) => TypeExpr::Tuple(
242                items
243                    .iter()
244                    .map(|item| recurse(self, item, visited))
245                    .collect(),
246            ),
247            TypeExpr::DictType(key, value) => TypeExpr::DictType(
248                Box::new(recurse(self, key, visited)),
249                Box::new(recurse(self, value, visited)),
250            ),
251            // An open shape's row tail, a generator/stream payload, an applied
252            // generic, and a function-typed parameter all carry inference
253            // obligations that a structural inline cannot preserve. Leave the
254            // whole parameter gradual rather than lower it wrongly.
255            TypeExpr::OpenShape { .. }
256            | TypeExpr::Generator(_)
257            | TypeExpr::Stream(_)
258            | TypeExpr::Applied { .. }
259            | TypeExpr::FnType { .. } => TypeExpr::Named("any".into()),
260            TypeExpr::Never | TypeExpr::LitString(_) | TypeExpr::LitInt(_) => ty.clone(),
261        }
262    }
263
264    fn local_type_decl(&self, module_path: &Path, name: &str) -> Option<SNode> {
265        let module = self
266            .modules
267            .get(module_path)
268            .or_else(|| self.modules.get(&normalize_path(module_path)))?;
269        module
270            .type_declarations
271            .iter()
272            .find(|decl| crate::type_decl_name(decl) == Some(name))
273            .cloned()
274    }
275}