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) = ¶m.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}