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