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}