Skip to main content

harn_builtin_meta/
lib.rs

1//! Const-constructible type definitions for Harn builtin signatures.
2//!
3//! Both `harn-parser` (for typechecking) and `harn-vm` (for runtime metadata)
4//! consume these shapes. Living in a dep-free crate lets the parser see the
5//! types without depending on the VM, and lets the `#[harn_builtin]` proc-macro
6//! emit `const` literals that link into either side.
7//!
8//! `Ty::to_type_expr` and friends, which convert into the parser's runtime
9//! `TypeExpr`, live in `harn-parser` since they depend on parser-internal AST.
10//!
11//! The [`shapes`] submodule holds the named structural-record consts
12//! (`LLM_CALL_OPTIONS`, `LLM_CALL_RESULT`, `TRANSCRIPT`, …) shared by the
13//! parser's static typechecking tables and the `#[harn_builtin]` macro's
14//! `@NAME` signature injection.
15
16pub mod contracts;
17pub mod host_capabilities;
18pub mod llm_options;
19pub mod predicate;
20pub mod runtime_type_tags;
21pub mod shapes;
22pub mod signatures;
23
24pub use contracts::{
25    wire_identifier_key, BuiltinContract, BuiltinExposure, CapabilityId, EffectAccess,
26    EffectAuthorization, EffectKind, EffectSpec, ResourceSelector,
27};
28
29/// A complete, static description of one builtin: identifier, arity range,
30/// per-parameter types, generic type parameters, return type, and any
31/// where-clause bounds the type checker should enforce on call.
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub struct BuiltinSignature {
34    /// Builtin name as registered in the VM and referenced from Harn source.
35    pub name: &'static str,
36    /// Positional parameters in declaration order. Trailing entries with
37    /// `optional: true` define the lower bound of the arity range; the
38    /// remaining entries plus `has_rest` define the upper bound.
39    pub params: &'static [Param],
40    /// Statically-known return type. Use [`Ty::Any`] when the return is
41    /// genuinely dynamic (e.g. `json_parse`).
42    pub returns: Ty,
43    /// Generic type parameter names declared on this builtin (e.g. `["T"]`
44    /// for `schema_parse<T>`).
45    pub type_params: &'static [&'static str],
46    /// True when the final parameter is variadic (rest). When set, the
47    /// effective arity upper bound is unbounded and the runtime will treat
48    /// trailing args as the rest-list.
49    pub has_rest: bool,
50    /// `where T: Foo` constraints. Each entry binds a generic type
51    /// parameter name to the name of an interface it must implement.
52    pub where_clauses: &'static [(&'static str, &'static str)],
53    /// Call-site record projection, when the result depends on selected keys.
54    /// `returns` remains the conservative contract for an indirect call.
55    pub projection: Option<RecordProjection>,
56}
57
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59pub enum RecordProjection {
60    Pick { source: usize, keys: usize },
61}
62
63impl BuiltinSignature {
64    /// Reuse a canonical signature shape under a projected source name.
65    pub const fn with_name(self, name: &'static str) -> Self {
66        Self { name, ..self }
67    }
68
69    pub const fn with_projection(self, projection: RecordProjection) -> Self {
70        Self {
71            projection: Some(projection),
72            ..self
73        }
74    }
75}
76
77/// One parameter slot inside a [`BuiltinSignature`].
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79pub struct Param {
80    pub name: &'static str,
81    pub ty: Ty,
82    /// True when this parameter has a default at the call site (so it may
83    /// be omitted). All optional params must be trailing.
84    pub optional: bool,
85}
86
87impl Param {
88    pub const fn new(name: &'static str, ty: Ty) -> Self {
89        Self {
90            name,
91            ty,
92            optional: false,
93        }
94    }
95
96    pub const fn optional(name: &'static str, ty: Ty) -> Self {
97        Self {
98            name,
99            ty,
100            optional: true,
101        }
102    }
103}
104
105/// `const`-friendly type IR used in builtin descriptors. Mirrors the runtime
106/// `TypeExpr` from `harn-parser` but is constructable in `const` position with
107/// no allocation. Convert to `TypeExpr` at the boundary via the parser-side
108/// `Ty::to_type_expr` helper.
109#[derive(Debug, Clone, Copy, PartialEq, Eq)]
110pub enum Ty {
111    /// A primitive or user-defined named type: `int`, `string`, `bool`,
112    /// `float`, `nil`, `bytes`, `dict`, `list`, `closure`, `duration`,
113    /// `any`, etc.
114    Named(&'static str),
115    /// Reference to a generic type parameter declared on the enclosing
116    /// signature (e.g. `Generic("T")`).
117    Generic(&'static str),
118    /// Untyped/dynamic. Skips type validation at runtime; the static
119    /// checker treats it as compatible with everything.
120    Any,
121    /// Optional sugar for `T | nil`.
122    Optional(&'static Ty),
123    /// Generic application: `List<T>` is `Apply("list", &[T])`,
124    /// `Result<T, E>` is `Apply("Result", &[T, E])`, `Schema<T>` is
125    /// [`Ty::SchemaOf`].
126    Apply(&'static str, &'static [Ty]),
127    /// Union of N alternatives. Empty unions are rejected by the
128    /// parser-side converter.
129    Union(&'static [Ty]),
130    /// Function type. Stores params and return as references so the literal
131    /// stays `Copy`.
132    Fn(&'static [Ty], &'static Ty),
133    /// Record/shape type with named fields. Closed: an argument carrying a
134    /// field that is not listed here is rejected.
135    Shape(&'static [ShapeFieldDescriptor]),
136    /// Open record: named fields plus one or more row tails, mirroring the
137    /// language's `{name: string, ...dict}`.
138    ///
139    /// Use this for a builtin that takes an extensible options/config dict.
140    /// A closed [`Ty::Shape`] would reject every key the signature does not
141    /// name, so such a parameter would otherwise have to fall back to a bare
142    /// `dict` and lose all field checking — including the declared type of a
143    /// `handler` slot.
144    OpenShape(&'static [ShapeFieldDescriptor], &'static [Ty]),
145    /// `Schema<T>` marker — semantically `Apply("Schema", &[Generic(T)])`
146    /// but distinguished so the type checker can pull the bound `T` from
147    /// the *value* of the schema arg (not its declared type).
148    SchemaOf(&'static str),
149    /// Bottom type (no return).
150    Never,
151    /// Integer literal type: `0`, `1`. Assignable to `int`.
152    LitInt(i64),
153    /// String literal type: `"pass"`. Assignable to `string`.
154    LitString(&'static str),
155}
156
157#[derive(Debug, Clone, Copy, PartialEq, Eq)]
158pub struct ShapeFieldDescriptor {
159    pub name: &'static str,
160    pub ty: Ty,
161    pub optional: bool,
162}
163
164impl ShapeFieldDescriptor {
165    pub const fn new(name: &'static str, ty: Ty) -> Self {
166        Self {
167            name,
168            ty,
169            optional: false,
170        }
171    }
172
173    pub const fn optional(name: &'static str, ty: Ty) -> Self {
174        Self {
175            name,
176            ty,
177            optional: true,
178        }
179    }
180}
181
182impl Ty {
183    /// True when this type carries no constraints (validation is a no-op).
184    pub const fn is_any(&self) -> bool {
185        matches!(self, Ty::Any)
186    }
187}
188
189/// Render shape fields and row tails in the `#[harn_builtin]` sig grammar.
190///
191/// An optional field is written `name?: ty`, not `name: ty?`. The trailing
192/// form does not round-trip: the sig parser folds a trailing `?` into the
193/// type as `ty | nil` and leaves the field required.
194fn write_shape_members(
195    f: &mut core::fmt::Formatter<'_>,
196    fields: &[ShapeFieldDescriptor],
197    rests: &[Ty],
198) -> core::fmt::Result {
199    for (i, fld) in fields.iter().enumerate() {
200        if i > 0 {
201            f.write_str(", ")?;
202        }
203        let name = fld.name;
204        let ty = &fld.ty;
205        let optional = if fld.optional { "?" } else { "" };
206        write!(f, "{name}{optional}: {ty}")?;
207    }
208    for (i, rest) in rests.iter().enumerate() {
209        if i > 0 || !fields.is_empty() {
210            f.write_str(", ")?;
211        }
212        write!(f, "...{rest}")?;
213    }
214    Ok(())
215}
216
217impl core::fmt::Display for Ty {
218    /// Render a parsed [`Ty`] back into the `#[harn_builtin]` sig grammar.
219    /// Round-trip target: parsing the output through the proc-macro's
220    /// sig parser yields a structurally-equal [`Ty`] (modulo whitespace and
221    /// canonical operator spacing). See the drift test in
222    /// `crates/harn-vm/tests/harn_vm/builtin_signature_text_drift.rs`.
223    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
224        match self {
225            Ty::Named(s) | Ty::Generic(s) => f.write_str(s),
226            Ty::Any => f.write_str("any"),
227            Ty::Never => f.write_str("never"),
228            Ty::Optional(inner) => write!(f, "{inner}?"),
229            Ty::Apply(name, args) => {
230                f.write_str(name)?;
231                f.write_str("<")?;
232                for (i, a) in args.iter().enumerate() {
233                    if i > 0 {
234                        f.write_str(", ")?;
235                    }
236                    write!(f, "{a}")?;
237                }
238                f.write_str(">")
239            }
240            Ty::Union(parts) => {
241                // Recover sig-grammar sugar so output round-trips through
242                // the proc-macro sig parser (which desugars `T?` and
243                // `number` into unions).
244                if let [inner, Ty::Named("nil")] = parts {
245                    if !matches!(inner, Ty::Named("nil")) {
246                        return write!(f, "{inner}?");
247                    }
248                }
249                if let [Ty::Named("int"), Ty::Named("float")] = parts {
250                    return f.write_str("number");
251                }
252                for (i, p) in parts.iter().enumerate() {
253                    if i > 0 {
254                        f.write_str(" | ")?;
255                    }
256                    write!(f, "{p}")?;
257                }
258                Ok(())
259            }
260            Ty::Fn(params, ret) => {
261                f.write_str("(")?;
262                for (i, p) in params.iter().enumerate() {
263                    if i > 0 {
264                        f.write_str(", ")?;
265                    }
266                    write!(f, "{p}")?;
267                }
268                write!(f, ") -> {ret}")
269            }
270            Ty::Shape(fields) => {
271                f.write_str("{")?;
272                write_shape_members(f, fields, &[])?;
273                f.write_str("}")
274            }
275            Ty::OpenShape(fields, rests) => {
276                f.write_str("{")?;
277                write_shape_members(f, fields, rests)?;
278                f.write_str("}")
279            }
280            Ty::SchemaOf(t) => write!(f, "Schema<{t}>"),
281            Ty::LitInt(n) => write!(f, "{n}"),
282            Ty::LitString(s) => write!(f, "\"{s}\""),
283        }
284    }
285}
286
287impl BuiltinSignature {
288    /// Non-generic, fixed-arity builtin: no type parameters, no rest, no
289    /// where-clause bounds. Covers ~70% of the registry; lets each call
290    /// site stay on a single logical line.
291    pub const fn simple(name: &'static str, params: &'static [Param], returns: Ty) -> Self {
292        Self {
293            name,
294            params,
295            returns,
296            type_params: &[],
297            has_rest: false,
298            where_clauses: &[],
299            projection: None,
300        }
301    }
302
303    /// Non-generic builtin whose final parameter is variadic (rest).
304    /// Equivalent to [`Self::simple`] with `has_rest: true`.
305    pub const fn variadic(name: &'static str, params: &'static [Param], returns: Ty) -> Self {
306        Self {
307            name,
308            params,
309            returns,
310            type_params: &[],
311            has_rest: true,
312            where_clauses: &[],
313            projection: None,
314        }
315    }
316
317    /// Generic, fixed-arity builtin: declares type parameters, no rest,
318    /// no where-clause bounds. Use the struct literal directly when both
319    /// generics and where-clauses or rest are needed.
320    pub const fn generic(
321        name: &'static str,
322        type_params: &'static [&'static str],
323        params: &'static [Param],
324        returns: Ty,
325    ) -> Self {
326        Self {
327            name,
328            params,
329            returns,
330            type_params,
331            has_rest: false,
332            where_clauses: &[],
333            projection: None,
334        }
335    }
336
337    /// Number of required parameters (those without defaults).
338    pub fn required_params(&self) -> usize {
339        self.params.iter().filter(|p| !p.optional).count()
340    }
341
342    /// True when this builtin recognises `name` as one of its declared
343    /// generic type parameters.
344    pub fn is_type_param(&self, name: &str) -> bool {
345        self.type_params.contains(&name)
346    }
347
348    /// True when this builtin declares any generic type parameters.
349    pub fn is_generic(&self) -> bool {
350        !self.type_params.is_empty()
351    }
352
353    /// Materialize the type parameter names as owned strings (for use in
354    /// the type checker's existing scope/binding APIs which key off
355    /// `Vec<String>`).
356    pub fn type_param_names(&self) -> Vec<String> {
357        self.type_params.iter().map(|s| (*s).to_string()).collect()
358    }
359
360    /// Where-clause constraints as `(type_param, interface)` strings.
361    pub fn where_clause_strings(&self) -> Vec<(String, String)> {
362        self.where_clauses
363            .iter()
364            .map(|(tp, iface)| ((*tp).to_string(), (*iface).to_string()))
365            .collect()
366    }
367}
368
369impl core::fmt::Display for BuiltinSignature {
370    /// Render a parsed [`BuiltinSignature`] back into the `#[harn_builtin]`
371    /// `sig = "..."` grammar. Used by the drift test and by tooling that
372    /// wants a canonical string form of the signature regardless of how it
373    /// was originally typed.
374    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
375        if !self.type_params.is_empty() {
376            f.write_str("<")?;
377            for (i, tp) in self.type_params.iter().enumerate() {
378                if i > 0 {
379                    f.write_str(", ")?;
380                }
381                f.write_str(tp)?;
382            }
383            if !self.where_clauses.is_empty() {
384                f.write_str(" where ")?;
385                for (i, (tp, iface)) in self.where_clauses.iter().enumerate() {
386                    if i > 0 {
387                        f.write_str(", ")?;
388                    }
389                    write!(f, "{tp}: {iface}")?;
390                }
391            }
392            f.write_str("> ")?;
393        }
394        f.write_str(self.name)?;
395        f.write_str("(")?;
396        let last_idx = self.params.len().saturating_sub(1);
397        for (i, p) in self.params.iter().enumerate() {
398            if i > 0 {
399                f.write_str(", ")?;
400            }
401            if self.has_rest && i == last_idx {
402                f.write_str("...")?;
403            }
404            f.write_str(p.name)?;
405            if p.optional {
406                f.write_str("?")?;
407            }
408            let ty = &p.ty;
409            write!(f, ": {ty}")?;
410        }
411        let ret = &self.returns;
412        write!(f, ") -> {ret}")
413    }
414}
415
416/// Public view of one builtin used by `harn-lint` and other crates that need
417/// just identifier + return-type hints (no parameter types).
418#[derive(Debug, Clone, Copy, PartialEq, Eq)]
419pub struct BuiltinMetadata {
420    pub name: &'static str,
421    pub return_types: &'static [&'static str],
422}
423
424// ---- Convenience constants ----
425//
426// Used pervasively in builtin signature literals to keep individual entries
427// terse. Add new constants here when a type appears repeatedly enough to
428// warrant a shorthand (avoid one-off shorthands).
429
430pub const TY_ANY: Ty = Ty::Any;
431pub const TY_BOOL: Ty = Ty::Named("bool");
432pub const TY_BYTES: Ty = Ty::Named("bytes");
433pub const TY_CLOSURE: Ty = Ty::Named("closure");
434pub const TY_DECIMAL: Ty = Ty::Named("decimal");
435pub const TY_DICT: Ty = Ty::Named("dict");
436pub const TY_DURATION: Ty = Ty::Named("duration");
437pub const TY_FLOAT: Ty = Ty::Named("float");
438pub const TY_INT: Ty = Ty::Named("int");
439pub const TY_LIST: Ty = Ty::Named("list");
440pub const TY_NEVER: Ty = Ty::Never;
441pub const TY_NIL: Ty = Ty::Named("nil");
442pub const TY_RESOURCE: Ty = Ty::Named("resource");
443pub const TY_STRING: Ty = Ty::Named("string");
444
445/// `string | nil`.
446pub const TY_STRING_OR_NIL: Ty = Ty::Union(&[TY_STRING, TY_NIL]);
447/// `int | nil`.
448pub const TY_INT_OR_NIL: Ty = Ty::Union(&[TY_INT, TY_NIL]);
449/// `dict | nil`.
450pub const TY_DICT_OR_NIL: Ty = Ty::Union(&[TY_DICT, TY_NIL]);
451/// `bytes | nil`.
452pub const TY_BYTES_OR_NIL: Ty = Ty::Union(&[TY_BYTES, TY_NIL]);
453/// `int | float`.
454pub const TY_NUMBER: Ty = Ty::Union(&[TY_INT, TY_FLOAT]);
455
456#[cfg(test)]
457mod tests {
458    use super::*;
459
460    const APPLY_ARGS: &[Ty] = &[TY_DICT];
461    const FN_PARAMS: &[Ty] = &[TY_INT, TY_STRING];
462    const SHAPE_FIELDS: &[ShapeFieldDescriptor] = &[
463        ShapeFieldDescriptor::new("name", TY_STRING),
464        ShapeFieldDescriptor::optional("age", TY_INT),
465    ];
466
467    const OPEN_SHAPE_RESTS: &[Ty] = &[TY_DICT];
468
469    #[test]
470    fn wire_identifier_keys_stay_unique_across_capabilities() {
471        // `from_host_namespace` resolves by normalized spelling, so two
472        // capabilities whose field names differ only by `_` or case would make
473        // every host-wire lookup ambiguous. Guard the vocabulary, not the
474        // lookup: the ambiguity would be introduced by adding a capability.
475        let mut seen = std::collections::HashMap::new();
476        for capability in CapabilityId::ALL {
477            let key = wire_identifier_key(capability.field_name());
478            if let Some(other) = seen.insert(key.clone(), capability.field_name()) {
479                panic!(
480                    "`{other}` and `{}` both normalize to `{key}`",
481                    capability.field_name()
482                );
483            }
484        }
485    }
486
487    #[test]
488    fn host_namespace_resolves_the_separatorless_spelling() {
489        assert_eq!(
490            CapabilityId::from_host_namespace("prmonitor"),
491            Some(CapabilityId::PrMonitor)
492        );
493        assert_eq!(
494            CapabilityId::from_host_namespace("PrMonitor"),
495            Some(CapabilityId::PrMonitor)
496        );
497        assert_eq!(
498            CapabilityId::from_host_namespace("pr_monitor"),
499            Some(CapabilityId::PrMonitor)
500        );
501        assert_eq!(CapabilityId::from_host_namespace("not_a_capability"), None);
502        // The exact parser stays exact: it reads the closed source vocabulary,
503        // where a spelling either is or is not the declared field name.
504        assert_eq!(CapabilityId::from_field_name("prmonitor"), None);
505    }
506
507    #[test]
508    fn ty_display_atomic_and_compound() {
509        assert_eq!(format!("{TY_INT}"), "int");
510        assert_eq!(format!("{TY_ANY}"), "any");
511        assert_eq!(format!("{TY_NEVER}"), "never");
512        // `T | nil` round-trips as `T?` (the sig grammar's optional sugar
513        // is desugared into a 2-element union, not `Ty::Optional`).
514        assert_eq!(format!("{TY_STRING_OR_NIL}"), "string?");
515        let opt_int = Ty::Optional(&TY_INT);
516        assert_eq!(format!("{opt_int}"), "int?");
517        // `int | float` round-trips as `number` (the predeclared shorthand).
518        assert_eq!(format!("{TY_NUMBER}"), "number");
519        let list_dict = Ty::Apply("list", APPLY_ARGS);
520        assert_eq!(format!("{list_dict}"), "list<dict>");
521        let lit_int = Ty::LitInt(42);
522        assert_eq!(format!("{lit_int}"), "42");
523        let lit_str = Ty::LitString("pass");
524        assert_eq!(format!("{lit_str}"), "\"pass\"");
525        let schema_t = Ty::SchemaOf("T");
526        assert_eq!(format!("{schema_t}"), "Schema<T>");
527        let fn_ty = Ty::Fn(FN_PARAMS, &TY_BOOL);
528        assert_eq!(format!("{fn_ty}"), "(int, string) -> bool");
529        let shape = Ty::Shape(SHAPE_FIELDS);
530        // An optional field renders as `age?: int`, not `age: int?`. The
531        // trailing form does not round-trip: the sig parser folds a trailing
532        // `?` into the type as `int | nil`, which leaves the field *required*
533        // and silently changes the contract.
534        assert_eq!(format!("{shape}"), "{name: string, age?: int}");
535        let open = Ty::OpenShape(SHAPE_FIELDS, OPEN_SHAPE_RESTS);
536        assert_eq!(format!("{open}"), "{name: string, age?: int, ...dict}");
537        let tail_only = Ty::OpenShape(&[], OPEN_SHAPE_RESTS);
538        assert_eq!(format!("{tail_only}"), "{...dict}");
539    }
540
541    const BASIC_PARAMS: &[Param] = &[Param::new("a", TY_DICT), Param::new("b", TY_DICT)];
542    const REST_PARAMS: &[Param] = &[Param::new("prefix", TY_STRING), Param::new("args", TY_ANY)];
543    const OPT_PARAMS: &[Param] = &[
544        Param::new("receipt", TY_DICT),
545        Param::optional("candidate", TY_ANY),
546    ];
547    const GENERIC_PARAMS: &[Param] = &[Param::new("schema", Ty::SchemaOf("T"))];
548
549    #[test]
550    fn signature_display_basic() {
551        let sig = BuiltinSignature::simple("deep_merge", BASIC_PARAMS, TY_DICT);
552        assert_eq!(format!("{sig}"), "deep_merge(a: dict, b: dict) -> dict");
553    }
554
555    #[test]
556    fn signature_display_with_optional_and_rest() {
557        let sig = BuiltinSignature {
558            name: "io_println",
559            params: REST_PARAMS,
560            returns: TY_NIL,
561            type_params: &[],
562            has_rest: true,
563            where_clauses: &[],
564            projection: None,
565        };
566        assert_eq!(
567            format!("{sig}"),
568            "io_println(prefix: string, ...args: any) -> nil"
569        );
570
571        let opt_sig =
572            BuiltinSignature::simple("lifecycle_replay_resume_input", OPT_PARAMS, TY_DICT);
573        assert_eq!(
574            format!("{opt_sig}"),
575            "lifecycle_replay_resume_input(receipt: dict, candidate?: any) -> dict"
576        );
577    }
578
579    #[test]
580    fn signature_display_with_generics_and_where() {
581        let sig = BuiltinSignature {
582            name: "schema_parse",
583            params: GENERIC_PARAMS,
584            returns: Ty::Generic("T"),
585            type_params: &["T"],
586            has_rest: false,
587            where_clauses: &[("T", "Decode")],
588            projection: None,
589        };
590        assert_eq!(
591            format!("{sig}"),
592            "<T where T: Decode> schema_parse(schema: Schema<T>) -> T"
593        );
594    }
595}