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}