Skip to main content

harn_parser/builtin_signatures/
lookup.rs

1//! Public lookup helpers over the unified [`BuiltinSignature`] registry.
2//!
3//! Both the type checker (this crate) and the runtime (`harn-vm`) consume
4//! these helpers. Generic builtins declare their type parameters via
5//! [`BuiltinSignature::type_params`] and use [`Ty::Generic`]/[`Ty::SchemaOf`]
6//! in param/return positions; the type checker materializes those at each
7//! call site against the surrounding scope.
8
9use crate::ast::TypeExpr;
10use harn_builtin_meta::{BuiltinExposure, CapabilityId};
11
12use super::signatures;
13use super::{BuiltinMetadata, BuiltinSignature, Ty, TyExt};
14
15/// Resolve the installed name index, then fall back to static signature groups.
16/// Installed entries win when both sides carry the same name (the
17/// `#[harn_builtin]`-emitted signature shadows any legacy static duplicate).
18/// Runtime validation calls this for every builtin invocation, so the owning
19/// registry lookup must not materialize or linearly scan its whole manifest.
20pub fn lookup(name: &str) -> Option<&'static BuiltinSignature> {
21    lookup_with_privileged_wire(name, false)
22}
23
24/// Resolve a builtin for an explicitly trusted host-dispatch compilation.
25/// This widens only `PrivilegedWire`; it does not restore legacy Harness
26/// methods or runtime-internal names as ambient globals.
27pub fn lookup_with_privileged_wire(
28    name: &str,
29    allow_privileged_wire: bool,
30) -> Option<&'static BuiltinSignature> {
31    if let Some(entry) = harn_builtin_registry::builtin_entry(name) {
32        return matches!(
33            entry.contract.exposure,
34            BuiltinExposure::PureGlobal | BuiltinExposure::CapabilityFunction { .. }
35        )
36        .then_some(entry.signature)
37        .or_else(|| {
38            (allow_privileged_wire && entry.contract.exposure == BuiltinExposure::PrivilegedWire)
39                .then_some(entry.signature)
40        })
41        .or_else(|| {
42            (crate::legacy_ambient_capabilities_enabled()
43                && matches!(
44                    entry.contract.exposure,
45                    BuiltinExposure::HarnessMethod { .. }
46                        | BuiltinExposure::PrivilegedWire
47                        | BuiltinExposure::StdlibInternal
48                        | BuiltinExposure::RuntimeInternal
49                ))
50            .then_some(entry.signature)
51        });
52    }
53    if crate::legacy_ambient_capabilities_enabled() {
54        if let Some(entry) = legacy_capability_method_entry(name) {
55            return Some(entry.signature);
56        }
57        if let Some(entry) = legacy_ambient_cap_global_entry(name) {
58            return Some(entry.signature);
59        }
60        if let Some(canonical) = crate::legacy_builtin_alias_target(name) {
61            return lookup(canonical);
62        }
63    }
64    static_signature_index().get(name).copied()
65}
66
67/// Name index over the hand-written static fallback tables.
68///
69/// The static groups hold several hundred signatures, and the fallback fires
70/// for *every* name the registry does not know — including every user-defined
71/// function call the type checker resolves — so a linear scan of every group
72/// per miss was a measurable slice of whole-file typechecking. The tables are
73/// `'static`, so one lazily-built index serves every lookup.
74fn static_signature_index(
75) -> &'static std::collections::HashMap<&'static str, &'static BuiltinSignature> {
76    static INDEX: std::sync::OnceLock<
77        std::collections::HashMap<&'static str, &'static BuiltinSignature>,
78    > = std::sync::OnceLock::new();
79    INDEX.get_or_init(|| {
80        let mut index = std::collections::HashMap::new();
81        for group in signatures::groups() {
82            for sig in group {
83                // First writer wins, matching the previous scan order.
84                index.entry(sig.name).or_insert(sig);
85            }
86        }
87        index
88    })
89}
90
91/// Resolve an unqualified legacy method name only when the typed manifest has
92/// exactly one owning Harness capability. Ambiguous method spellings remain
93/// unavailable rather than selecting authority by registration order.
94pub fn legacy_capability_method_entry(
95    name: &str,
96) -> Option<&'static harn_builtin_registry::BuiltinManifestEntry> {
97    let mut matches = ambient_harness_method_entries()
98        .into_iter()
99        .filter(|entry| {
100            matches!(
101                entry.contract.exposure,
102                BuiltinExposure::HarnessMethod { method, .. } if method == name
103            )
104        });
105    let entry = matches.next()?;
106    matches.next().is_none().then_some(entry)
107}
108
109/// Resolve a pre-cutover ambient global whose typed contract is published under
110/// the hidden `__cap_<name>` spelling (for example `runtime_context_set` →
111/// `__cap_runtime_context_set` for `harness.runtime.context_set`).
112pub fn legacy_ambient_cap_global_entry(
113    name: &str,
114) -> Option<&'static harn_builtin_registry::BuiltinManifestEntry> {
115    ambient_harness_method_entries()
116        .into_iter()
117        .find(|entry| entry.name.strip_prefix("__cap_") == Some(name))
118}
119
120/// Resolve a privileged-wire builtin published as `__<name>` (for example
121/// ambient `security_policy` → `__security_policy`).
122pub fn legacy_privileged_wire_entry(
123    name: &str,
124) -> Option<&'static harn_builtin_registry::BuiltinManifestEntry> {
125    harn_builtin_registry::installed_manifest()
126        .into_iter()
127        .find(|entry| {
128            matches!(entry.contract.exposure, BuiltinExposure::PrivilegedWire)
129                && entry.name.strip_prefix("__") == Some(name)
130        })
131}
132
133/// Canonical runtime builtin name for an ambient call site under the legacy
134/// bridge.
135///
136/// Only rewrite when the runtime registers a different spelling than the
137/// source call. Privileged-wire builtins publish as `__name`. Host internals
138/// (`__host_*`) and capability `__cap_*` contracts keep their short ambient
139/// names; the VM projects those globals under the ambient bridge.
140pub fn legacy_ambient_runtime_name(name: &str) -> Option<&'static str> {
141    if let Some(target) = crate::legacy_builtin_alias_target(name) {
142        return Some(target);
143    }
144    legacy_privileged_wire_entry(name).map(|entry| entry.name)
145}
146
147fn ambient_harness_method_entries() -> Vec<&'static harn_builtin_registry::BuiltinManifestEntry> {
148    // Once the CLI/runtime installs the process manifest, prefer it alone.
149    // Chaining the static capability-contracts table on top duplicates every
150    // `__cap_*` method and makes `legacy_capability_method_entry` treat unique
151    // owners as ambiguous (two identical matches), which breaks ambient check.
152    let installed = harn_builtin_registry::installed_manifest();
153    if installed.is_empty() {
154        harn_capability_contracts::manifest().to_vec()
155    } else {
156        installed
157    }
158}
159
160/// Resolve the signature paired with one capability method contract.
161pub fn lookup_capability_method(
162    capability: CapabilityId,
163    method: &str,
164) -> Option<&'static BuiltinSignature> {
165    capability_method_entry(capability.field_name(), method).map(|entry| entry.signature)
166}
167
168/// Resolve the single manifest entry that owns a `harness.<field>.<method>`
169/// call. Consumers that need effects or the internal dispatch name use this
170/// rather than reconstructing either from strings.
171pub fn capability_method_entry(
172    field: &str,
173    method: &str,
174) -> Option<&'static harn_builtin_registry::BuiltinManifestEntry> {
175    let capability = CapabilityId::from_field_name(field)?;
176    harn_builtin_registry::installed_manifest()
177        .iter()
178        .copied()
179        .find(|entry| {
180            matches!(
181                entry.contract.exposure,
182                BuiltinExposure::HarnessMethod {
183                    capability: candidate,
184                    method: candidate_method,
185                } if candidate == capability && candidate_method == method
186            )
187        })
188        .or_else(|| harn_capability_contracts::capability_method_entry(field, method))
189}
190
191/// Is `name` a builtin known to the parser?
192pub fn is_builtin(name: &str) -> bool {
193    lookup(name).is_some()
194        || (crate::legacy_ambient_capabilities_enabled()
195            && crate::is_registered_legacy_hostlib_name(name))
196}
197
198pub fn is_builtin_with_privileged_wire(name: &str, allow_privileged_wire: bool) -> bool {
199    lookup_with_privileged_wire(name, allow_privileged_wire).is_some()
200        || (crate::legacy_ambient_capabilities_enabled()
201            && crate::is_registered_legacy_hostlib_name(name))
202}
203
204/// Every builtin name. Installed names come first, then any static-only
205/// names that aren't shadowed by installed entries. Output is NOT
206/// alphabetically sorted (callers that need that re-sort themselves).
207pub fn iter_builtin_names() -> impl Iterator<Item = &'static str> {
208    let installed: Vec<_> = harn_builtin_registry::installed_manifest()
209        .into_iter()
210        .filter(|entry| {
211            matches!(
212                entry.contract.exposure,
213                BuiltinExposure::PureGlobal | BuiltinExposure::CapabilityFunction { .. }
214            )
215        })
216        .collect();
217    let installed_names: std::collections::HashSet<&'static str> =
218        harn_builtin_registry::installed_manifest()
219            .into_iter()
220            .map(|entry| entry.name)
221            .collect();
222    installed.into_iter().map(|entry| entry.name).chain(
223        signatures::groups()
224            .into_iter()
225            .flat_map(|g| g.iter())
226            .filter(move |s| !installed_names.contains(s.name))
227            .map(|s| s.name),
228    )
229}
230
231/// Names that come *only* from the hand-written static fallback tables
232/// (`signatures::groups()`), independent of whatever the driver installed.
233///
234/// Exposed so cross-crate drift guards (see the builtin-registry alignment
235/// test in `harn-vm`) can assert the static tables never overlap with
236/// `#[harn_builtin]`-published or `runtime_only` macro builtins — the exact
237/// duplication that let LLM config signatures silently drift before the
238/// shapes-in-`harn-builtin-meta` migration.
239pub fn static_signature_names() -> impl Iterator<Item = &'static str> {
240    signatures::groups()
241        .into_iter()
242        .flat_map(|g| g.iter())
243        .map(|s| s.name)
244}
245
246/// Iterate over every builtin's name and statically-known return-type
247/// strings. Used by `harn-lint` and other consumers that want a
248/// lightweight "what does this builtin return" view without bringing in
249/// the full type IR.
250pub fn iter_builtin_metadata() -> impl Iterator<Item = BuiltinMetadata> {
251    let installed: Vec<_> = harn_builtin_registry::installed_manifest()
252        .into_iter()
253        .filter(|entry| {
254            matches!(
255                entry.contract.exposure,
256                BuiltinExposure::PureGlobal | BuiltinExposure::CapabilityFunction { .. }
257            )
258        })
259        .collect();
260    let installed_names: std::collections::HashSet<&'static str> =
261        harn_builtin_registry::installed_manifest()
262            .into_iter()
263            .map(|entry| entry.name)
264            .collect();
265    installed
266        .into_iter()
267        .map(|entry| BuiltinMetadata {
268            name: entry.name,
269            return_types: builtin_return_type_names(entry.signature),
270        })
271        .chain(
272            signatures::groups()
273                .into_iter()
274                .flat_map(|g| g.iter())
275                .filter(move |s| !installed_names.contains(s.name))
276                .map(|sig| BuiltinMetadata {
277                    name: sig.name,
278                    return_types: builtin_return_type_names(sig),
279                }),
280        )
281}
282
283/// Statically-known return type for `name`, materialized as a [`TypeExpr`].
284/// Returns `None` for unknown names AND for builtins whose return type is
285/// genuinely dynamic ([`Ty::Any`]).
286pub fn builtin_return_type(name: &str) -> Option<TypeExpr> {
287    let sig = lookup(name)?;
288    if sig.returns.is_any() {
289        return None;
290    }
291    Some(sig.returns.to_type_expr())
292}
293
294/// Builtins that produce an untyped, opaque value — parsed text, a network
295/// body, a model response, a host or tool result — which strict-types mode
296/// requires validating before field access.
297///
298/// **One owner for the question.** `HARN-OWN-004` in the typechecker and
299/// `HARN-LNT-029` in the linter both read this. They used to keep separate
300/// hand-maintained copies, which had drifted on six names: only the
301/// typechecker knew `connector_call`, `host_tool_call`, `http_download`,
302/// `http_stream_info`, and `llm_call_safe`, and only the linter knew
303/// `mcp_call`. This list is their union.
304pub const UNTYPED_BOUNDARY_SOURCES: &[&str] = &[
305    "json_parse",
306    "json_extract",
307    "yaml_parse",
308    "toml_parse",
309    "llm_call",
310    "llm_call_safe",
311    "llm_completion",
312    "http_get",
313    "http_post",
314    "http_put",
315    "http_patch",
316    "http_delete",
317    "http_download",
318    "http_request",
319    "http_session_request",
320    "http_stream_info",
321    "sse_receive",
322    "sse_server_mock_receive",
323    "sse_server_response",
324    "sse_server_status",
325    "websocket_accept",
326    "websocket_receive",
327    "host_call",
328    "connector_call",
329    "host_tool_call",
330    "mcp_call",
331];
332
333/// Returns true if this builtin produces an untyped/opaque value that
334/// should be validated before field access in strict types mode.
335pub fn is_untyped_boundary_source(name: &str) -> bool {
336    UNTYPED_BOUNDARY_SOURCES.contains(&name)
337}
338
339/// The same question for the typed spelling: does `harness.<field>.<method>`
340/// name one of [`UNTYPED_BOUNDARY_SOURCES`]?
341///
342/// A call site that adopts the spelling `HARN-LNT-071` asks for is still
343/// reading unvalidated data — only the syntax changed. Resolving through the
344/// same list keeps one answer for both spellings, instead of a rule going
345/// quiet the moment its subject migrates.
346pub fn is_untyped_boundary_capability_method(field: &str, method: &str) -> bool {
347    UNTYPED_BOUNDARY_SOURCES
348        .iter()
349        .filter_map(|name| harness_method_for_ambient_name(name))
350        .any(|(candidate_field, candidate_method)| {
351            candidate_field == field && candidate_method == method
352        })
353}
354
355/// Whether a capability-method entry publishes the contract for the ambient
356/// builtin `ambient`.
357///
358/// Three spellings are in use and no single one covers the surface: the
359/// ambient name itself (`llm_call`, in the installed manifest),
360/// `__cap_<name>` (`__cap_llm_call`, in the static capability contracts), and
361/// `__cap_<capability>_<name>` (`__cap_tools_mcp_call`). Matching only one of
362/// them answers "not a boundary source" for part of the list, and does it
363/// silently.
364fn entry_publishes_ambient_name(entry_name: &str, capability_field: &str, ambient: &str) -> bool {
365    if entry_name == ambient {
366        return true;
367    }
368    let Some(rest) = entry_name.strip_prefix("__cap_") else {
369        return false;
370    };
371    rest == ambient
372        || rest
373            .strip_prefix(capability_field)
374            .and_then(|rest| rest.strip_prefix('_'))
375            == Some(ambient)
376}
377
378/// Where an ambient builtin moved onto a Harness handle, as
379/// `(capability field, method)`.
380///
381/// Two registration paths answer this and neither subsumes the other. A
382/// builtin with a `HarnessMethod` contract carries the pair itself. The
383/// `http_*` family never reached the builtin manifest at all — it is
384/// parser-only, with no runtime contract — so its mapping lives in the ambient
385/// replacement tables beside the other pre-cutover families.
386fn harness_method_for_ambient_name(name: &str) -> Option<(&'static str, &'static str)> {
387    // `ambient_harness_method_entries` falls back to the static capability
388    // contracts when no VM has installed the process manifest. Reading
389    // `installed_manifest` directly would make this answer "not a boundary
390    // source" for every manifest-owned builtin during parser-only checking —
391    // silently, and only for some of the list, since the `http_*` family
392    // resolves through the replacement tables either way.
393    let contract_owned = ambient_harness_method_entries()
394        .into_iter()
395        .find_map(|entry| {
396            let BuiltinExposure::HarnessMethod { capability, method } = entry.contract.exposure
397            else {
398                return None;
399            };
400            entry_publishes_ambient_name(entry.name, capability.field_name(), name)
401                .then_some((capability.field_name(), method))
402        });
403    if contract_owned.is_some() {
404        return contract_owned;
405    }
406    let path = crate::diagnostic::harness_net_replacement(name)
407        .or_else(|| crate::diagnostic::harness_fs_replacement(name))
408        .or_else(|| crate::diagnostic::harness_env_replacement(name))
409        .or_else(|| crate::diagnostic::harness_stdio_replacement(name))
410        .or_else(|| crate::diagnostic::harness_clock_replacement(name))
411        .or_else(|| crate::diagnostic::harness_random_replacement(name))?;
412    path.strip_prefix("harness.")?.split_once('.')
413}
414
415/// Convert the signature's return type to a tiny `&'static [&'static str]`
416/// view used by `BuiltinMetadata` consumers (linter, LSP) that don't
417/// pull in the full type IR. Only basic primitive names and the common
418/// `T | nil` unions are exposed; everything else returns an empty slice
419/// so callers know to consult [`builtin_return_type`] instead.
420fn builtin_return_type_names(sig: &BuiltinSignature) -> &'static [&'static str] {
421    match &sig.returns {
422        Ty::Named(name) => match *name {
423            "bool" => &["bool"],
424            "bytes" => &["bytes"],
425            "dict" => &["dict"],
426            "float" => &["float"],
427            "int" => &["int"],
428            "list" => &["list"],
429            "nil" => &["nil"],
430            "string" => &["string"],
431            _ => &[],
432        },
433        Ty::Union(members) => match *members {
434            [Ty::Named("string"), Ty::Named("nil")] => &["string", "nil"],
435            [Ty::Named("nil"), Ty::Named("string")] => &["string", "nil"],
436            [Ty::Named("int"), Ty::Named("nil")] => &["int", "nil"],
437            [Ty::Named("nil"), Ty::Named("int")] => &["int", "nil"],
438            [Ty::Named("dict"), Ty::Named("nil")] => &["dict", "nil"],
439            [Ty::Named("nil"), Ty::Named("dict")] => &["dict", "nil"],
440            [Ty::Named("bytes"), Ty::Named("nil")] => &["bytes", "nil"],
441            [Ty::Named("nil"), Ty::Named("bytes")] => &["bytes", "nil"],
442            _ => &[],
443        },
444        Ty::Never => &["never"],
445        _ => &[],
446    }
447}
448
449#[cfg(test)]
450mod ambient_install_regression {
451    use super::*;
452
453    #[test]
454    fn installed_manifest_does_not_shadow_ambient_capability_methods() {
455        // Process-global env; restore afterwards so later tests in this
456        // process do not inherit the legacy bridge (it previously leaked and
457        // made strict-mode typechecker tests order-dependent).
458        let previous = std::env::var_os("HARN_LEGACY_AMBIENT_CAPABILITIES");
459        std::env::set_var("HARN_LEGACY_AMBIENT_CAPABILITIES", "1");
460        crate::refresh_legacy_ambient_capabilities();
461        assert!(
462            is_builtin("store_set"),
463            "capability-contracts fallback must resolve ambient store_set"
464        );
465
466        // Project the same contracts the CLI installs before `harn check`.
467        let entries: &'static [&'static harn_builtin_registry::BuiltinManifestEntry] = Box::leak(
468            harn_capability_contracts::manifest()
469                .to_vec()
470                .into_boxed_slice(),
471        );
472        harn_builtin_registry::install_builtin_manifest(entries);
473
474        assert!(
475            is_builtin("store_set"),
476            "after manifest install, ambient store_set must still resolve uniquely"
477        );
478        assert!(
479            legacy_capability_method_entry("store_set").is_some(),
480            "legacy_capability_method_entry must stay unique after install"
481        );
482
483        match previous {
484            Some(value) => std::env::set_var("HARN_LEGACY_AMBIENT_CAPABILITIES", value),
485            None => std::env::remove_var("HARN_LEGACY_AMBIENT_CAPABILITIES"),
486        }
487        crate::refresh_legacy_ambient_capabilities();
488    }
489}