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, Default, 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, Default, 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    /// Source span covering the type reference's surface text. Populated
99    /// by the parser for every `TypeRef` it constructs; synthesized refs
100    /// (stdlib signatures, analyzer alias substitutions, test fixtures,
101    /// …) default to `Span::default()`.
102    ///
103    /// For composite types (`list[T]`), the OUTER `TypeRef`'s span covers
104    /// the whole `list[T]` text and the INNER `T`'s span covers just the
105    /// inner identifier. For optional sentinels (`T?`), the inner `T`'s
106    /// span is the pre-`?` text and the outer sentinel's span includes
107    /// the `?`. For variant unions (`A | B | ...`) and choice strings
108    /// (`"a" | "b"`), each arm carries its own span and the outer
109    /// sentinel spans the whole union text.
110    ///
111    /// `#[serde(default)]` so AST payloads serialized before this field
112    /// existed (older SDK clients, durable execution caches) keep
113    /// deserializing cleanly.
114    #[serde(default)]
115    pub span: Span,
116}
117
118/// Sentinel `TypeRef.name` for a discriminated union (`A | B | ...`).
119pub const VARIANT_UNION_SENTINEL: &str = "variant_union";
120
121/// Sentinel `TypeRef.name` for an optional type (`T?`). The `inner` field
122/// holds the wrapped `T`. `none` is assignable to any optional type;
123/// `T?` is NOT assignable to `T` without an explicit `?? default` unwrap
124/// or a pattern-match on `none`. (D2)
125pub const OPTIONAL_SENTINEL: &str = "optional";
126
127impl TypeRef {
128    /// Build a primitive or named type reference (no generic inner, no
129    /// choice variants). Use this in preference to a struct literal so the
130    /// `choices` field stays consistently `None` at non-choice sites.
131    ///
132    /// The resulting `span` is `Span::default()` — appropriate for
133    /// synthesized refs (stdlib signatures, analyzer substitutions, test
134    /// fixtures). Parser sites that have a concrete source span should
135    /// use [`TypeRef::primitive_with_span`] instead so hover /
136    /// goto-definition land on the right text range.
137    pub fn primitive(name: impl Into<String>) -> Self {
138        Self {
139            name: name.into(),
140            inner: None,
141            choices: None,
142            variants: None,
143            span: Span::default(),
144        }
145    }
146
147    /// Build a primitive or named type reference with an explicit source
148    /// span. Parser-facing companion to [`TypeRef::primitive`].
149    pub fn primitive_with_span(name: impl Into<String>, span: Span) -> Self {
150        Self {
151            name: name.into(),
152            inner: None,
153            choices: None,
154            variants: None,
155            span,
156        }
157    }
158
159    /// Build an optional type `T?` wrapping `inner` (D2). Idempotent:
160    /// applying this to a type that is already optional returns the same
161    /// shape (no double-wrap), matching most languages' `T??` collapse.
162    ///
163    /// The outer sentinel's `span` is `Span::default()` — parser sites
164    /// that have the postfix `?` location should use
165    /// [`TypeRef::optional_with_span`] so the outer sentinel covers the
166    /// whole `T?` text (the inner `T`'s span stays at its own range).
167    pub fn optional(inner: TypeRef) -> Self {
168        if inner.is_optional() {
169            return inner;
170        }
171        Self {
172            name: OPTIONAL_SENTINEL.to_string(),
173            inner: Some(Box::new(inner)),
174            choices: None,
175            variants: None,
176            span: Span::default(),
177        }
178    }
179
180    /// Build an optional type `T?` with an explicit source span covering
181    /// the whole `T?` text (inner `T`'s span is preserved). Parser-facing
182    /// companion to [`TypeRef::optional`].
183    pub fn optional_with_span(inner: TypeRef, span: Span) -> Self {
184        if inner.is_optional() {
185            return inner;
186        }
187        Self {
188            name: OPTIONAL_SENTINEL.to_string(),
189            inner: Some(Box::new(inner)),
190            choices: None,
191            variants: None,
192            span,
193        }
194    }
195
196    /// `true` iff this `TypeRef` is an `Optional[T]` sentinel.
197    pub fn is_optional(&self) -> bool {
198        self.name == OPTIONAL_SENTINEL && self.inner.is_some()
199    }
200
201    /// Borrow the wrapped `T` from an `Optional[T]`; `None` for non-optional.
202    pub fn optional_inner(&self) -> Option<&TypeRef> {
203        if self.is_optional() {
204            self.inner.as_deref()
205        } else {
206            None
207        }
208    }
209
210    /// Build a discriminated union from an ordered arm list. Grammar
211    /// guarantees `arms.len() >= 2`; arm-count caps are analyzer-enforced
212    /// (`AKRIBES-E-UNION-009`) so oversized unions reach the analyzer as
213    /// parsed ASTs instead of panicking in the parser. The binary
214    /// `T | Unable` case is just `variant_union(vec![T, Unable])`.
215    pub fn variant_union(arms: Vec<TypeRef>) -> Self {
216        debug_assert!(arms.len() >= 2, "variant union requires >= 2 arms");
217        Self {
218            name: VARIANT_UNION_SENTINEL.to_string(),
219            inner: None,
220            choices: None,
221            variants: Some(arms),
222            span: Span::default(),
223        }
224    }
225
226    /// Build a discriminated union with an explicit outer span covering
227    /// the whole `A | B | ...` text. Each arm's `TypeRef` keeps its own
228    /// span (arms come from the parser already populated). Parser-facing
229    /// companion to [`TypeRef::variant_union`].
230    pub fn variant_union_with_span(arms: Vec<TypeRef>, span: Span) -> Self {
231        debug_assert!(arms.len() >= 2, "variant union requires >= 2 arms");
232        Self {
233            name: VARIANT_UNION_SENTINEL.to_string(),
234            inner: None,
235            choices: None,
236            variants: Some(arms),
237            span,
238        }
239    }
240
241    /// Build a binary union `success | Unable`. Kept as a named constructor
242    /// because every #157 call site uses it; internally delegates to
243    /// [`variant_union`] with `[success, Unable]` in canonical source
244    /// order.
245    pub fn union_with_unable(success: TypeRef) -> Self {
246        Self::variant_union(vec![success, TypeRef::primitive("Unable")])
247    }
248
249    /// Return `true` iff this `TypeRef` is a discriminated-union sentinel
250    /// (any arm count).
251    pub fn is_variant_union(&self) -> bool {
252        self.name == VARIANT_UNION_SENTINEL && self.variants.is_some()
253    }
254
255    /// Slice over the declared arms in source order (or `None` for
256    /// non-union types).
257    pub fn union_arms(&self) -> Option<&[TypeRef]> {
258        self.variants.as_deref()
259    }
260
261    /// Return `true` iff this `TypeRef` is a binary union whose two arms
262    /// are exactly one non-Unable record and one `Unable`. Used by every
263    /// #157 call site that gates on "this is a T | Unable return type" —
264    /// kept for backwards compatibility and cheap pattern-matching.
265    pub fn is_union_with_unable(&self) -> bool {
266        match self.variants.as_deref() {
267            Some(arms) if arms.len() == 2 => {
268                (arms[0].name == "Unable") ^ (arms[1].name == "Unable")
269            }
270            _ => false,
271        }
272    }
273
274    /// Return the non-Unable branch of a binary `T | Unable`, or `None` if
275    /// this is not exactly such a union. N-ary unions and unions without
276    /// an `Unable` arm return `None` — callers that need the general arm
277    /// list should use [`union_arms`].
278    pub fn unwrap_union_success(&self) -> Option<&TypeRef> {
279        match self.variants.as_deref() {
280            Some(arms) if arms.len() == 2 => {
281                if arms[0].name == "Unable" && arms[1].name != "Unable" {
282                    Some(&arms[1])
283                } else if arms[1].name == "Unable" && arms[0].name != "Unable" {
284                    Some(&arms[0])
285                } else {
286                    None
287                }
288            }
289            _ => None,
290        }
291    }
292
293    /// Return the declared success arm of any discriminated union — the
294    /// first arm in source order. Used for retry gating and
295    /// `on <variant> default` type-checking. Returns `None` for non-union
296    /// types.
297    pub fn union_success_arm(&self) -> Option<&TypeRef> {
298        self.variants.as_deref().and_then(|arms| arms.first())
299    }
300
301    /// Render a `TypeRef` as a source-level fragment for error messages,
302    /// LSP labels, and user-facing diagnostics. Union types render as
303    /// `A | B | ...`; choice types render as `"a" | "b" | ...`; generics
304    /// render as `list[str]`; primitives render as their `name`.
305    pub fn display(&self) -> String {
306        // D2: `Optional[T]` renders as `T?` at the source level, mirroring
307        // the postfix syntax authors typed.
308        if let Some(inner) = self.optional_inner() {
309            return format!("{}?", inner.display());
310        }
311        if let Some(arms) = &self.variants {
312            return arms
313                .iter()
314                .map(|a| a.display())
315                .collect::<Vec<_>>()
316                .join(" | ");
317        }
318        if let Some(choices) = &self.choices {
319            choices
320                .iter()
321                .map(|c| format!("\"{}\"", c))
322                .collect::<Vec<_>>()
323                .join(" | ")
324        } else if let Some(inner) = &self.inner {
325            format!("{}[{}]", self.name, inner.display())
326        } else {
327            self.name.clone()
328        }
329    }
330}
331
332impl fmt::Display for TypeRef {
333    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
334        f.write_str(&self.display())
335    }
336}
337
338#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
339pub enum ActorHint {
340    Human,
341    Any,
342    Client(String),
343}
344
345#[cfg(test)]
346mod tests {
347    use super::*;
348
349    /// Span derives `Default` — required so existing `TypeRef { … }`
350    /// struct literals across the workspace can keep building without
351    /// every test fixture having to construct one by hand.
352    #[test]
353    fn span_default_is_all_zero() {
354        let s = Span::default();
355        assert_eq!(s.line, 0);
356        assert_eq!(s.col, 0);
357        assert_eq!(s.end_line, 0);
358        assert_eq!(s.end_col, 0);
359    }
360
361    #[test]
362    fn typeref_primitive_has_default_span() {
363        let ty = TypeRef::primitive("str");
364        assert_eq!(ty.span, Span::default());
365    }
366
367    #[test]
368    fn typeref_primitive_with_span_carries_span() {
369        let s = Span {
370            line: 4,
371            col: 7,
372            end_line: 4,
373            end_col: 10,
374        };
375        let ty = TypeRef::primitive_with_span("str", s.clone());
376        assert_eq!(ty.span, s);
377    }
378
379    #[test]
380    fn typeref_serde_roundtrip_preserves_span() {
381        let s = Span {
382            line: 2,
383            col: 5,
384            end_line: 2,
385            end_col: 9,
386        };
387        let ty = TypeRef::primitive_with_span("str", s.clone());
388        let json = serde_json::to_string(&ty).unwrap();
389        let back: TypeRef = serde_json::from_str(&json).unwrap();
390        assert_eq!(back.name, "str");
391        assert_eq!(back.span, s);
392    }
393
394    /// The pre-`span` wire shape. Older SDK clients and durable execution
395    /// caches serialized TypeRefs without a `span` field, so the new
396    /// deserializer MUST accept those payloads and default-fill the
397    /// span. This is the load-bearing compatibility guarantee.
398    #[test]
399    fn typeref_deserializes_legacy_payload_without_span_field() {
400        let legacy = r#"{"name":"str","inner":null,"choices":null}"#;
401        let ty: TypeRef = serde_json::from_str(legacy).unwrap();
402        assert_eq!(ty.name, "str");
403        assert_eq!(ty.span, Span::default());
404    }
405
406    #[test]
407    fn typeref_deserializes_legacy_list_payload_without_span() {
408        let legacy =
409            r#"{"name":"list","inner":{"name":"str","inner":null,"choices":null},"choices":null}"#;
410        let ty: TypeRef = serde_json::from_str(legacy).unwrap();
411        assert_eq!(ty.name, "list");
412        assert_eq!(ty.span, Span::default());
413        let inner = ty.inner.as_deref().unwrap();
414        assert_eq!(inner.name, "str");
415        assert_eq!(inner.span, Span::default());
416    }
417
418    #[test]
419    fn typeref_deserializes_legacy_choice_payload_without_span() {
420        let legacy = r#"{"name":"choice","inner":null,"choices":["a","b"]}"#;
421        let ty: TypeRef = serde_json::from_str(legacy).unwrap();
422        assert_eq!(ty.name, "choice");
423        assert_eq!(
424            ty.choices.as_deref().unwrap(),
425            &["a".to_string(), "b".to_string()]
426        );
427        assert_eq!(ty.span, Span::default());
428    }
429
430    /// Variant-union shapes already used `#[serde(default,
431    /// skip_serializing_if = "Option::is_none")]` for `variants`, so a
432    /// legacy payload without `variants` round-trips fine alongside a
433    /// missing `span`. Lock that in.
434    #[test]
435    fn typeref_deserializes_legacy_payload_without_variants_or_span() {
436        let legacy = r#"{"name":"str","inner":null,"choices":null}"#;
437        let ty: TypeRef = serde_json::from_str(legacy).unwrap();
438        assert_eq!(ty.name, "str");
439        assert!(ty.variants.is_none());
440        assert_eq!(ty.span, Span::default());
441    }
442
443    #[test]
444    fn typeref_variant_union_roundtrip_preserves_arm_spans() {
445        let arm_a = TypeRef::primitive_with_span(
446            "A",
447            Span {
448                line: 1,
449                col: 1,
450                end_line: 1,
451                end_col: 2,
452            },
453        );
454        let arm_b = TypeRef::primitive_with_span(
455            "B",
456            Span {
457                line: 1,
458                col: 5,
459                end_line: 1,
460                end_col: 6,
461            },
462        );
463        let outer = TypeRef::variant_union_with_span(
464            vec![arm_a, arm_b],
465            Span {
466                line: 1,
467                col: 1,
468                end_line: 1,
469                end_col: 6,
470            },
471        );
472        let json = serde_json::to_string(&outer).unwrap();
473        let back: TypeRef = serde_json::from_str(&json).unwrap();
474        assert!(back.is_variant_union());
475        let arms = back.union_arms().unwrap();
476        assert_eq!(arms[0].name, "A");
477        assert_eq!(arms[0].span.col, 1);
478        assert_eq!(arms[1].name, "B");
479        assert_eq!(arms[1].span.col, 5);
480        assert_eq!(back.span.end_col, 6);
481    }
482
483    #[test]
484    fn typeref_optional_with_span_returns_inner_if_already_optional() {
485        // Idempotent collapse with a different outer span: the second
486        // wrap is a no-op and returns the first-level optional
487        // unchanged.
488        let inner = TypeRef::primitive_with_span(
489            "int",
490            Span {
491                line: 1,
492                col: 1,
493                end_line: 1,
494                end_col: 4,
495            },
496        );
497        let first = TypeRef::optional_with_span(
498            inner,
499            Span {
500                line: 1,
501                col: 1,
502                end_line: 1,
503                end_col: 5,
504            },
505        );
506        let twice = TypeRef::optional_with_span(
507            first.clone(),
508            Span {
509                line: 9,
510                col: 9,
511                end_line: 9,
512                end_col: 9,
513            },
514        );
515        assert_eq!(twice.span, first.span);
516    }
517}