Skip to main content

akribes_types/
ast.rs

1//! AST shapes that travel over the wire.
2//!
3//! This is the SDK-facing slice of `akribes_core::ast`: just the types
4//! that consumers need to interpret engine events ([`Span`], [`TypeRef`],
5//! [`TypeField`], [`ActorHint`], [`FieldConstraint`] and the associated
6//! sentinel constants). The full `akribes_core::ast` module also defines
7//! `Stmt`, `Expr`, `Program`, and the rest of the language AST, which
8//! stays in core because the parser/analyzer/compiler own those shapes.
9
10use serde::{Deserialize, Serialize};
11use std::fmt;
12
13#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
14pub struct Span {
15    pub line: usize,
16    pub col: usize,
17    pub end_line: usize,
18    pub end_col: usize,
19}
20
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct TypeField {
23    pub name: String,
24    pub ty: TypeRef,
25    pub docs: Option<String>,
26    pub span: Span,
27    /// Field-level validation constraints declared via `matches /re/`,
28    /// `at_least_items 3`, prose `"..."` lines, etc. Attached by the parser
29    /// to the most recent field whose `span.col` matches the constraint's
30    /// column (see `parser.rs` constraint attachment rules). `#[serde(default)]`
31    /// so ASTs serialized before constraints existed still deserialize.
32    #[serde(default)]
33    pub constraints: Vec<FieldConstraint>,
34}
35
36/// A single field-level validation constraint attached to a `type` field.
37/// Parsed from the Constraint Mini-Language (see
38/// `docs/superpowers/specs/2026-04-18-epa-constraint-language-design.md`).
39#[derive(Debug, Clone, Serialize, Deserialize)]
40pub enum FieldConstraint {
41    /// A structured (Tier-1) constraint like `matches /^[A-Z0-9]+$/` or
42    /// `at_least_items 3`. `phrase` is the canonical phrase name (e.g.
43    /// `"matches"`) — the key the `ConstraintRegistry` uses to look up the
44    /// handler. `args` is a handler-specific JSON payload (for `matches` this
45    /// is `{"pattern": "<regex>"}`).
46    Tier1 {
47        phrase: String,
48        args: serde_json::Value,
49        span: Span,
50    },
51    /// An unrecognized Tier-2 prose rule, e.g.
52    /// `"must be a valid ticker symbol"`. Rendered verbatim into the prompt
53    /// in Tier-1 prose form; never enforced at runtime.
54    ProseRule { text: String, span: Span },
55    /// A `validate_with: <ident>` custom-validator hook. `name` is the
56    /// validator's canonical identifier — resolved against the
57    /// `validation::validator_registry::VALIDATORS` registry at
58    /// analysis time (emits `AKRIBES-E-VALIDATE-WITH-UNKNOWN` on misses) and
59    /// dispatched at task-end (failures surface as
60    /// `AKRIBES-E-VALIDATE-WITH-FAIL` corrective retries).
61    ValidateWith { name: String, span: Span },
62}
63
64impl FieldConstraint {
65    pub fn span(&self) -> &Span {
66        match self {
67            FieldConstraint::Tier1 { span, .. } => span,
68            FieldConstraint::ProseRule { span, .. } => span,
69            FieldConstraint::ValidateWith { span, .. } => span,
70        }
71    }
72}
73
74/// Upper cap on discriminated-union arms. 8 is the tightest reliable
75/// value across Anthropic tool-use + Gemini `responseSchema` under
76/// preliminary testing. The analyzer raises `AKRIBES-E-UNION-009` when an
77/// arm list exceeds this.
78pub const MAX_UNION_ARMS: usize = 8;
79
80#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct TypeRef {
82    pub name: String,
83    pub inner: Option<Box<TypeRef>>,
84    /// Populated only for string-literal union types; in that case `name` is
85    /// the sentinel `"choice"` and `choices` holds the variant strings in
86    /// declaration order. `None` for every other type. Variant validation
87    /// (non-empty, unique, ≥2) is the analyzer's job, not the parser's.
88    pub choices: Option<Vec<String>>,
89    /// Populated only for discriminated-union types (general `A | B | ...`
90    /// including the binary `T | Unable` special case). When `Some`, `name`
91    /// is the sentinel `"variant_union"` (mirroring `"choice"`), both
92    /// `inner` and `choices` are `None`, and `variants` holds every arm in
93    /// source order with length in `[2, MAX_UNION_ARMS]`. The analyzer
94    /// enforces arm-record-only, ≤8, no duplicates, and
95    /// return-position-only usage in v1.
96    #[serde(default, skip_serializing_if = "Option::is_none")]
97    pub variants: Option<Vec<TypeRef>>,
98}
99
100/// Sentinel `TypeRef.name` for a discriminated union (`A | B | ...`).
101pub const VARIANT_UNION_SENTINEL: &str = "variant_union";
102
103/// Sentinel `TypeRef.name` for an optional type (`T?`). The `inner` field
104/// holds the wrapped `T`. `none` is assignable to any optional type;
105/// `T?` is NOT assignable to `T` without an explicit `?? default` unwrap
106/// or a pattern-match on `none`. (D2)
107pub const OPTIONAL_SENTINEL: &str = "optional";
108
109impl TypeRef {
110    /// Build a primitive or named type reference (no generic inner, no
111    /// choice variants). Use this in preference to a struct literal so the
112    /// `choices` field stays consistently `None` at non-choice sites.
113    pub fn primitive(name: impl Into<String>) -> Self {
114        Self {
115            name: name.into(),
116            inner: None,
117            choices: None,
118            variants: None,
119        }
120    }
121
122    /// Build an optional type `T?` wrapping `inner` (D2). Idempotent:
123    /// applying this to a type that is already optional returns the same
124    /// shape (no double-wrap), matching most languages' `T??` collapse.
125    pub fn optional(inner: TypeRef) -> Self {
126        if inner.is_optional() {
127            return inner;
128        }
129        Self {
130            name: OPTIONAL_SENTINEL.to_string(),
131            inner: Some(Box::new(inner)),
132            choices: None,
133            variants: None,
134        }
135    }
136
137    /// `true` iff this `TypeRef` is an `Optional[T]` sentinel.
138    pub fn is_optional(&self) -> bool {
139        self.name == OPTIONAL_SENTINEL && self.inner.is_some()
140    }
141
142    /// Borrow the wrapped `T` from an `Optional[T]`; `None` for non-optional.
143    pub fn optional_inner(&self) -> Option<&TypeRef> {
144        if self.is_optional() {
145            self.inner.as_deref()
146        } else {
147            None
148        }
149    }
150
151    /// Build a discriminated union from an ordered arm list. Grammar
152    /// guarantees `arms.len() >= 2`; arm-count caps are analyzer-enforced
153    /// (`AKRIBES-E-UNION-009`) so oversized unions reach the analyzer as
154    /// parsed ASTs instead of panicking in the parser. The binary
155    /// `T | Unable` case is just `variant_union(vec![T, Unable])`.
156    pub fn variant_union(arms: Vec<TypeRef>) -> Self {
157        debug_assert!(arms.len() >= 2, "variant union requires >= 2 arms");
158        Self {
159            name: VARIANT_UNION_SENTINEL.to_string(),
160            inner: None,
161            choices: None,
162            variants: Some(arms),
163        }
164    }
165
166    /// Build a binary union `success | Unable`. Kept as a named constructor
167    /// because every #157 call site uses it; internally delegates to
168    /// [`variant_union`] with `[success, Unable]` in canonical source
169    /// order.
170    pub fn union_with_unable(success: TypeRef) -> Self {
171        Self::variant_union(vec![success, TypeRef::primitive("Unable")])
172    }
173
174    /// Return `true` iff this `TypeRef` is a discriminated-union sentinel
175    /// (any arm count).
176    pub fn is_variant_union(&self) -> bool {
177        self.name == VARIANT_UNION_SENTINEL && self.variants.is_some()
178    }
179
180    /// Slice over the declared arms in source order (or `None` for
181    /// non-union types).
182    pub fn union_arms(&self) -> Option<&[TypeRef]> {
183        self.variants.as_deref()
184    }
185
186    /// Return `true` iff this `TypeRef` is a binary union whose two arms
187    /// are exactly one non-Unable record and one `Unable`. Used by every
188    /// #157 call site that gates on "this is a T | Unable return type" —
189    /// kept for backwards compatibility and cheap pattern-matching.
190    pub fn is_union_with_unable(&self) -> bool {
191        match self.variants.as_deref() {
192            Some(arms) if arms.len() == 2 => {
193                (arms[0].name == "Unable") ^ (arms[1].name == "Unable")
194            }
195            _ => false,
196        }
197    }
198
199    /// Return the non-Unable branch of a binary `T | Unable`, or `None` if
200    /// this is not exactly such a union. N-ary unions and unions without
201    /// an `Unable` arm return `None` — callers that need the general arm
202    /// list should use [`union_arms`].
203    pub fn unwrap_union_success(&self) -> Option<&TypeRef> {
204        match self.variants.as_deref() {
205            Some(arms) if arms.len() == 2 => {
206                if arms[0].name == "Unable" && arms[1].name != "Unable" {
207                    Some(&arms[1])
208                } else if arms[1].name == "Unable" && arms[0].name != "Unable" {
209                    Some(&arms[0])
210                } else {
211                    None
212                }
213            }
214            _ => None,
215        }
216    }
217
218    /// Return the declared success arm of any discriminated union — the
219    /// first arm in source order. Used for retry gating and
220    /// `on <variant> default` type-checking. Returns `None` for non-union
221    /// types.
222    pub fn union_success_arm(&self) -> Option<&TypeRef> {
223        self.variants.as_deref().and_then(|arms| arms.first())
224    }
225
226    /// Render a `TypeRef` as a source-level fragment for error messages,
227    /// LSP labels, and user-facing diagnostics. Union types render as
228    /// `A | B | ...`; choice types render as `"a" | "b" | ...`; generics
229    /// render as `list[str]`; primitives render as their `name`.
230    pub fn display(&self) -> String {
231        // D2: `Optional[T]` renders as `T?` at the source level, mirroring
232        // the postfix syntax authors typed.
233        if let Some(inner) = self.optional_inner() {
234            return format!("{}?", inner.display());
235        }
236        if let Some(arms) = &self.variants {
237            return arms
238                .iter()
239                .map(|a| a.display())
240                .collect::<Vec<_>>()
241                .join(" | ");
242        }
243        if let Some(choices) = &self.choices {
244            choices
245                .iter()
246                .map(|c| format!("\"{}\"", c))
247                .collect::<Vec<_>>()
248                .join(" | ")
249        } else if let Some(inner) = &self.inner {
250            format!("{}[{}]", self.name, inner.display())
251        } else {
252            self.name.clone()
253        }
254    }
255}
256
257impl fmt::Display for TypeRef {
258    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
259        f.write_str(&self.display())
260    }
261}
262
263#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
264pub enum ActorHint {
265    Human,
266    Any,
267    Client(String),
268}