Skip to main content

praxis_input_parser/
synthesize.rs

1//! Compile-time result-type synthesis (§7.8).
2//!
3//! Given a validated [`ParserAst`], derive its statically known result [`Type`].
4//! This is the §7.8 derivation table: `lines(P)` → `Vec[result(P)]`, `grid(P)`
5//! → `Grid[result(P)]`, named-capture templates → anonymous records, etc.
6//!
7//! The result type drives inference (`read` / `parse` expressions get this type
8//! directly — there is no callee scheme to unify against) and is what hover
9//! displays.
10
11use crate::ast::{AtomicKind, ParserAst, TemplatePart};
12use praxis_source::Span;
13use praxis_typeck::{
14    CollectionCtor, EnumVariantDef, FieldSet, TupleElems, Type, TypeCtorError, TypeDb, VariantSet,
15};
16
17/// Synthesize the result type of a parser expression (§7.8).
18///
19/// Normally called on an AST that has already passed
20/// [`validate`](crate::validate::validate), but the error channel is real
21/// rather than an `expect`: the shapes this builds — an anonymous record
22/// per named-capture template, an anonymous enum per `choice` — are exactly the
23/// ones whose *names come from user source*, so a duplicate is user input, not
24/// an internal inconsistency. `validate` catches those cases today; threading
25/// the `Result` is what keeps the two from drifting apart silently.
26pub fn synthesize(ast: &ParserAst, db: &mut TypeDb) -> Result<Type, TypeCtorError> {
27    let mut discard = Vec::new();
28    synth(ast, db, &mut discard)
29}
30
31/// [`synthesize`], keeping the type it derived for **every** node on the way
32/// (ADR-098), not only the root's.
33///
34/// §15.3 asks for the synthesized type of *a* parser expression, and an inner
35/// constructor is one, so hover on `lines(…)` inside `sections(lines(…))` has
36/// something to read.
37///
38/// The entries come out in **post-order** — a node is recorded after the
39/// children it was derived from — so where two nodes share a span the earlier
40/// entry is the deeper one. That is the tie-break the cursor lookup uses; it is
41/// not cosmetic, because `` `{int}` `` gives a single-capture template and its
42/// capture's parser genuinely equal extents.
43///
44/// # Errors
45/// The same [`TypeCtorError`]s [`synthesize`] answers.
46pub fn synthesize_indexed(
47    ast: &ParserAst,
48    db: &mut TypeDb,
49) -> Result<(Type, Vec<(Span, Type)>), TypeCtorError> {
50    let mut out = Vec::new();
51    let ty = synth(ast, db, &mut out)?;
52    Ok((ty, out))
53}
54
55/// The §7.8 derivation table — **one implementation**, walked once, recording as
56/// it goes. `synthesize` and `synthesize_indexed` differ only in whether they
57/// keep the recording.
58fn synth(
59    ast: &ParserAst,
60    db: &mut TypeDb,
61    out: &mut Vec<(Span, Type)>,
62) -> Result<Type, TypeCtorError> {
63    let ty = synth_inner(ast, db, out)?;
64    // Recorded **after** the recursion, so children precede their parent. A
65    // `Type` has no forgeable value to reserve a slot with (it is a sealed arena
66    // index), which is the other reason this is post-order and not a patched-in
67    // placeholder.
68    out.push((ast.span(), ty));
69    Ok(ty)
70}
71
72fn synth_inner(
73    ast: &ParserAst,
74    db: &mut TypeDb,
75    out: &mut Vec<(Span, Type)>,
76) -> Result<Type, TypeCtorError> {
77    Ok(match ast {
78        ParserAst::Atomic { kind, .. } => atomic_type(*kind, db),
79        ParserAst::Template { parts, .. } => template_type(parts, db, out)?,
80        ParserAst::Lines { child, .. }
81        | ParserAst::Sections { child, .. }
82        | ParserAst::Csv { child, .. }
83        | ParserAst::Ws { child, .. }
84        | ParserAst::Sep { child, .. } => {
85            // The four ways of cutting the input into elements, plus `sep`'s
86            // fifth → `Vec[result(P)]` (§7.8). What separates the elements is a
87            // parse-time question; it is nothing to the result type, which is
88            // why `sep`'s separator does not appear here.
89            let elem = synth(child, db, out)?;
90            db.vec(elem)
91        }
92        ParserAst::Grid { child, .. }
93        | ParserAst::Matrix { child, .. }
94        | ParserAst::GridRagged { child, .. } => {
95            // `grid(P)` / `matrix(P)` / ragged `grid(P)` → Grid[result(P)]
96            // (§7.5, ADR-030). Ragged's `fill` pads short rows during the parse
97            // and, like `sep`'s separator, is not part of the type.
98            let elem = synth(child, db, out)?;
99            db.unary_collection(CollectionCtor::Grid, elem)
100        }
101        ParserAst::SectionsNamed {
102            fields,
103            repeated_tail,
104            ..
105        } => {
106            // Anonymous record: one field per named argument, in source order,
107            // plus a final `Vec[result(P)]` field for the unbounded `repeated`
108            // tail (if any). A counted group is a `Vec[result(P)]` too — the
109            // same field the tail contributes, in the position it was written,
110            // which is what lets a fixed field follow one.
111            let mut rec_fields: Vec<(String, Type)> = Vec::with_capacity(fields.len());
112            for item in fields {
113                let elem = synth(item.parser(), db, out)?;
114                let ty = match item {
115                    crate::ast::SectionItem::One { .. } => elem,
116                    crate::ast::SectionItem::Counted { .. } => db.vec(elem),
117                };
118                rec_fields.push((item.name().to_string(), ty));
119            }
120            if let Some((name, tail)) = repeated_tail {
121                let elem = synth(tail, db, out)?;
122                rec_fields.push((name.clone(), db.vec(elem)));
123            }
124            db.record(None, FieldSet::from_pairs(rec_fields)?)
125        }
126        ParserAst::Block { items, .. } => {
127            // Flattened anonymous record (§7.5): positional named-capture
128            // templates contribute their capture fields; named items contribute
129            // one field each.
130            let mut rec_fields: Vec<(String, Type)> = Vec::new();
131            for item in items {
132                match item {
133                    crate::ast::BlockItem::Positional(p) => {
134                        if let ParserAst::Template { parts, .. } = p {
135                            for part in parts {
136                                if let TemplatePart::Capture {
137                                    name: Some(n),
138                                    parser,
139                                    ..
140                                } = part
141                                {
142                                    rec_fields
143                                        .push((n.as_str().to_string(), synth(parser, db, out)?));
144                                }
145                            }
146                        }
147                    }
148                    crate::ast::BlockItem::Named { name, parser } => {
149                        rec_fields.push((name.clone(), synth(parser, db, out)?));
150                    }
151                }
152            }
153            db.record(None, FieldSet::from_pairs(rec_fields)?)
154        }
155        ParserAst::Choice { cases, .. } => {
156            // Anonymous enum (§7.5): one variant per case, each carrying the
157            // case's result type as a single-element payload (so the parsed
158            // value is recoverable via match). Identity is name+signature-based
159            // via the anonymous-enum unify arm and the absent name.
160            let mut variants: Vec<EnumVariantDef> = Vec::with_capacity(cases.len());
161            for (name, p) in cases {
162                let payload_ty = synth(p, db, out)?;
163                variants.push(EnumVariantDef::new(name.clone(), vec![payload_ty]));
164            }
165            db.enum_(None, VariantSet::new(variants)?)
166        }
167        ParserAst::Optional { child, .. } => {
168            // `Option[result(P)]` (§7.5/§7.8): the prelude's one `Option` def,
169            // applied to the child's result type.
170            let elem = synth(child, db, out)?;
171            db.option_of(elem)
172        }
173        ParserAst::Scan { child, .. } => {
174            // `scan(P)` → `Vec[result(P)]` (§7.5): matches in source order.
175            let elem = synth(child, db, out)?;
176            db.vec(elem)
177        }
178        ParserAst::OneOf { .. } => {
179            // `one_of("LR")` → Char (§7.5).
180            db.char()
181        }
182        ParserAst::Characters { child, .. } => {
183            // `chars(P, skip:)` → `Vec[result(P)]` (§7.5, ADR-079). The element
184            // type is *derived* from `P` rather than assumed to be `Char`, so it
185            // cannot disagree with the values the parse stores:
186            // `chars(one_of("LR"))` is `Vec[Char]` because `one_of` synthesizes
187            // `Char`, and `chars(int, skip: none)` is `Vec[Int]`.
188            let elem = synth(child, db, out)?;
189            db.vec(elem)
190        }
191    })
192}
193
194/// The class of result §7.4's ten atomics produce — five for ten kinds.
195///
196/// **Stated here because it is answered twice, on either side of the
197/// parser-planner/parser-executor boundary.** `atomic_type` turns a class
198/// into the static [`Type`]; `praxis-runtime`'s `atomic_descriptor` turns the
199/// same class into the runtime `TypeDescriptor` a collection carries for its
200/// elements. A descriptor that disagrees with the static type behind it is a
201/// defect, and exhaustiveness cannot prevent it: an eleventh atomic forces both
202/// sites to be *touched* but not to make the same grouping decision. There is
203/// one decision, and the two sides only choose how to spell its answer.
204///
205/// Deliberately its own enum rather than a [`praxis_typeck::ScalarType`]:
206/// `praxis-runtime` does not depend on `praxis-typeck` (it already depends on
207/// this crate, so there is no cycle), and `UInt` — the one scalar neither side
208/// may answer — is not nameable here at all. See [`AtomicClass::of`].
209#[derive(Clone, Copy, Debug, PartialEq, Eq)]
210pub enum AtomicClass {
211    /// `int`, `uint`, `digit`.
212    Int,
213    /// `float`.
214    Float,
215    /// `byte`.
216    Byte,
217    /// `char`.
218    Char,
219    /// `word`, `identifier`, `text`, `rest`.
220    Text,
221}
222
223impl AtomicClass {
224    /// Classify an atomic by the result it produces (§7.4).
225    ///
226    /// `uint` is `Int`, deliberately, in the static type and at runtime alike
227    /// (§7.4): `ScalarType::UInt` is reserved and has no runtime object
228    /// (`praxis_repr::builtin_for_type` answers `NoRuntimeRepr`), so there is
229    /// no descriptor for the runtime side to answer, and typing a `uint`
230    /// capture as `UInt` would make every program containing one fail to
231    /// compile under D9. The non-negativity is the *parse rule*, in
232    /// `walk_atomic`.
233    pub fn of(kind: AtomicKind) -> AtomicClass {
234        match kind {
235            AtomicKind::Int | AtomicKind::UInt | AtomicKind::Digit => AtomicClass::Int,
236            AtomicKind::Float => AtomicClass::Float,
237            AtomicKind::Byte => AtomicClass::Byte,
238            AtomicKind::Char => AtomicClass::Char,
239            AtomicKind::Word | AtomicKind::Identifier | AtomicKind::Text | AtomicKind::Rest => {
240                AtomicClass::Text
241            }
242        }
243    }
244}
245
246/// The result type of an atomic parser (§7.4). Which kinds share a type is
247/// [`AtomicClass::of`]'s decision, not this function's.
248fn atomic_type(kind: AtomicKind, db: &mut TypeDb) -> Type {
249    match AtomicClass::of(kind) {
250        AtomicClass::Int => db.int(),
251        AtomicClass::Float => db.float(),
252        AtomicClass::Byte => db.scalar(praxis_typeck::ScalarType::Byte),
253        AtomicClass::Char => db.char(),
254        AtomicClass::Text => db.text(),
255    }
256}
257
258/// The result type of a template (§7.3). The shapes and the reason they are
259/// what they are live on [`TemplateShape`](crate::plan::TemplateShape); this is
260/// the same classification over *AST* parts, one step before lowering, so it
261/// answers a `Type` where that one answers a runtime descriptor.
262///
263/// The two are deliberately not one generic function: threading a trait across
264/// `TemplatePart`/`TemplatePartNode` would buy nothing (ADR-092). The two
265/// classifications must agree — a descriptor that disagrees with the static type
266/// is a defect — so if a shape is ever added, it is added in both places.
267fn template_type(
268    parts: &[TemplatePart],
269    db: &mut TypeDb,
270    out: &mut Vec<(Span, Type)>,
271) -> Result<Type, TypeCtorError> {
272    let captures: Vec<&TemplatePart> = parts
273        .iter()
274        .filter(|p| matches!(p, TemplatePart::Capture { .. }))
275        .collect();
276
277    if captures.is_empty() {
278        // A template with no captures matches literally and produces Unit.
279        return Ok(db.unit());
280    }
281
282    let any_named = captures
283        .iter()
284        .any(|p| matches!(p, TemplatePart::Capture { name: Some(_), .. }));
285
286    if any_named {
287        // Named captures → anonymous structural record (§5.6, ADR-025).
288        return record_type(&captures, db, out);
289    }
290
291    // All anonymous: scalar if one, tuple if many (§7.3).
292    let mut elem_types: Vec<Type> = Vec::with_capacity(captures.len());
293    for p in &captures {
294        let TemplatePart::Capture { parser, .. } = p else {
295            unreachable!("filtered to captures")
296        };
297        elem_types.push(synth(parser, db, out)?);
298    }
299    if elem_types.len() == 1 {
300        Ok(elem_types[0])
301    } else {
302        Ok(db.tuple(TupleElems::new(elem_types)?))
303    }
304}
305
306/// Build an anonymous record type from named captures.
307///
308/// Named-capture templates produce anonymous structural records (§5.6). The
309/// record type is a proper `TypeData::Record` variant (ADR-025), with fields
310/// keyed by name. Two records with the same field names (in any order) and
311/// structurally-equal types share one type.
312fn record_type(
313    captures: &[&TemplatePart],
314    db: &mut TypeDb,
315    out: &mut Vec<(Span, Type)>,
316) -> Result<Type, TypeCtorError> {
317    // Collect (name, type) pairs in source order. Display preserves this order;
318    // identity is name-set-based (§5.6), established through unification.
319    let mut fields = Vec::with_capacity(captures.len());
320    for part in captures {
321        match part {
322            TemplatePart::Capture { name, parser, .. } => {
323                let name_str = name
324                    .as_ref()
325                    .map(|n| n.as_str().to_string())
326                    .unwrap_or_default();
327                fields.push((name_str, synth(parser, db, out)?));
328            }
329            _ => unreachable!("filtered to captures"),
330        }
331    }
332    Ok(db.record(None, FieldSet::from_pairs(fields)?))
333}
334
335#[cfg(test)]
336mod tests {
337    use super::*;
338    use crate::ast::{AtomicKind, CaptureName, TemplatePart};
339    use praxis_source::Span;
340
341    fn atom(kind: AtomicKind) -> ParserAst {
342        ParserAst::Atomic {
343            kind,
344            span: Span::at(0),
345        }
346    }
347
348    /// Every one of §7.4's ten atomics has a result type, and `uint`'s is
349    /// `Int`.
350    ///
351    /// Not `ScalarType::UInt`: `praxis_repr::builtin_for_type` answers
352    /// `NoRuntimeRepr` for `UInt` (it is reserved and has no runtime object,
353    /// pinned by `a_type_with_no_runtime_object_has_no_descriptor`), and under
354    /// D9 a JIT compile *fails* when a descriptor is missing — so a `uint`
355    /// capture typed `UInt` would make every program containing one fail to
356    /// compile. §7.4's non-negativity is the parse rule instead.
357    #[test]
358    fn every_atomic_the_design_requires_has_a_type() {
359        use praxis_typeck::{ScalarType, TypeData};
360        let mut db = TypeDb::new();
361        for kind in AtomicKind::ALL {
362            let t = synthesize(&atom(*kind), &mut db).expect("an atomic synthesizes");
363            let expected = match kind {
364                AtomicKind::Int | AtomicKind::UInt | AtomicKind::Digit => ScalarType::Int,
365                AtomicKind::Float => ScalarType::Float,
366                AtomicKind::Byte => ScalarType::Byte,
367                AtomicKind::Char => ScalarType::Char,
368                AtomicKind::Word | AtomicKind::Identifier | AtomicKind::Text | AtomicKind::Rest => {
369                    ScalarType::Text
370                }
371            };
372            match db.data(t) {
373                TypeData::Scalar(s) => assert_eq!(*s, expected, "for `{}`", kind.keyword()),
374                other => panic!("`{}` must be a scalar, got {other:?}", kind.keyword()),
375            }
376            assert!(
377                !matches!(db.data(t), TypeData::Scalar(ScalarType::UInt)),
378                "`{}` must not be typed UInt: it has no runtime object",
379                kind.keyword()
380            );
381        }
382    }
383
384    #[test]
385    fn atomic_int_synthesizes_int() {
386        let mut db = TypeDb::new();
387        let t = synthesize(&atom(AtomicKind::Int), &mut db).expect("int synthesizes");
388        // TypeDb does not deduplicate handles; compare by data shape.
389        assert!(matches!(
390            db.data(t),
391            praxis_typeck::TypeData::Scalar(praxis_typeck::ScalarType::Int)
392        ));
393    }
394
395    #[test]
396    fn lines_of_int_is_vec_int() {
397        let mut db = TypeDb::new();
398        let ast = ParserAst::Lines {
399            child: Box::new(atom(AtomicKind::Int)),
400            span: Span::at(0),
401        };
402        let t = synthesize(&ast, &mut db).expect("a valid AST synthesizes");
403        match db.data(t) {
404            praxis_typeck::TypeData::Collection { ctor, args } => {
405                assert_eq!(*ctor, CollectionCtor::Vec);
406                assert_eq!(args.len(), 1);
407                assert!(
408                    matches!(
409                        db.data(args[0]),
410                        praxis_typeck::TypeData::Scalar(praxis_typeck::ScalarType::Int)
411                    ),
412                    "the Vec element must be Int, got {}",
413                    db.render(args[0])
414                );
415            }
416            other => panic!("expected Vec, got {other:?}"),
417        }
418    }
419
420    #[test]
421    fn grid_of_char_is_grid_char() {
422        let mut db = TypeDb::new();
423        let ast = ParserAst::Grid {
424            child: Box::new(atom(AtomicKind::Char)),
425            span: Span::at(0),
426        };
427        let t = synthesize(&ast, &mut db).expect("a valid AST synthesizes");
428        match db.data(t) {
429            praxis_typeck::TypeData::Collection { ctor, args } => {
430                assert_eq!(*ctor, CollectionCtor::Grid);
431                assert_eq!(args.len(), 1);
432                assert!(
433                    matches!(
434                        db.data(args[0]),
435                        praxis_typeck::TypeData::Scalar(praxis_typeck::ScalarType::Char)
436                    ),
437                    "the Grid element must be Char, got {}",
438                    db.render(args[0])
439                );
440            }
441            other => panic!("expected Grid, got {other:?}"),
442        }
443    }
444
445    #[test]
446    fn nested_sections_lines_csv_int() {
447        // sections(lines(csv(int))) → Vec[Vec[Vec[Int]]]
448        let mut db = TypeDb::new();
449        let ast = ParserAst::Sections {
450            child: Box::new(ParserAst::Lines {
451                child: Box::new(ParserAst::Csv {
452                    child: Box::new(atom(AtomicKind::Int)),
453                    span: Span::at(0),
454                }),
455                span: Span::at(0),
456            }),
457            span: Span::at(0),
458        };
459        let t = synthesize(&ast, &mut db).expect("a valid AST synthesizes");
460        // Walk three Vec levels.
461        let mut current = t;
462        for level in 1..=3 {
463            let praxis_typeck::TypeData::Collection { ctor, args } = db.data(current) else {
464                panic!("level {level} should be Vec, got {}", db.render(current));
465            };
466            assert_eq!(*ctor, CollectionCtor::Vec, "wrong ctor at level {level}");
467            assert_eq!(args.len(), 1, "wrong arity at level {level}");
468            current = args[0];
469        }
470        assert!(
471            matches!(
472                db.data(current),
473                praxis_typeck::TypeData::Scalar(praxis_typeck::ScalarType::Int)
474            ),
475            "nested leaf must be Int, got {}",
476            db.render(current)
477        );
478    }
479
480    #[test]
481    fn template_single_anonymous_capture_is_scalar() {
482        let mut db = TypeDb::new();
483        let ast = ParserAst::Template {
484            parts: vec![TemplatePart::Capture {
485                name: None,
486                parser: Box::new(atom(AtomicKind::Int)),
487                span: Span::at(0),
488                name_span: None,
489            }],
490            span: Span::at(0),
491        };
492        let t = synthesize(&ast, &mut db).expect("a valid AST synthesizes");
493        // Single anonymous capture → scalar Int.
494        assert!(matches!(
495            db.data(t),
496            praxis_typeck::TypeData::Scalar(praxis_typeck::ScalarType::Int)
497        ));
498    }
499
500    #[test]
501    fn template_two_anonymous_captures_is_tuple() {
502        let mut db = TypeDb::new();
503        let ast = ParserAst::Template {
504            parts: vec![
505                TemplatePart::Capture {
506                    name: None,
507                    parser: Box::new(atom(AtomicKind::Int)),
508                    span: Span::at(0),
509                    name_span: None,
510                },
511                TemplatePart::Capture {
512                    name: None,
513                    parser: Box::new(atom(AtomicKind::Int)),
514                    span: Span::at(0),
515                    name_span: None,
516                },
517            ],
518            span: Span::at(0),
519        };
520        let t = synthesize(&ast, &mut db).expect("a valid AST synthesizes");
521        assert!(matches!(db.data(t), praxis_typeck::TypeData::Tuple(_)));
522    }
523
524    #[test]
525    fn template_named_captures_synthesize_anonymous_record() {
526        // `{x:int},{y:int}` → anonymous record { x: Int, y: Int }.
527        let mut db = TypeDb::new();
528        let ast = ParserAst::Template {
529            parts: vec![
530                TemplatePart::Capture {
531                    name: Some(CaptureName::parse("x").expect("an identifier")),
532                    parser: Box::new(atom(AtomicKind::Int)),
533                    span: Span::at(0),
534                    name_span: None,
535                },
536                TemplatePart::Capture {
537                    name: Some(CaptureName::parse("y").expect("an identifier")),
538                    parser: Box::new(atom(AtomicKind::Int)),
539                    span: Span::at(0),
540                    name_span: None,
541                },
542            ],
543            span: Span::at(0),
544        };
545        let t = synthesize(&ast, &mut db).expect("a valid AST synthesizes");
546        let praxis_typeck::TypeData::Record { def, .. } = db.data(t) else {
547            panic!("expected Record, got {:?}", db.data(t));
548        };
549        let rdef = db.record_def(*def);
550        assert!(rdef.name.is_none(), "anonymous record has no name");
551        assert_eq!(rdef.arity(), 2);
552        let (idx, _) = rdef.field("x").expect("field x");
553        assert_eq!(idx, 0);
554        // Renders as the structural record form.
555        assert_eq!(db.render(t), "{ x: Int, y: Int }");
556    }
557
558    #[test]
559    fn lines_of_named_captures_is_vec_of_record() {
560        // lines(`{x:int},{y:int}`) → Vec[{ x: Int, y: Int }].
561        let mut db = TypeDb::new();
562        let ast = ParserAst::Lines {
563            child: Box::new(ParserAst::Template {
564                parts: vec![
565                    TemplatePart::Capture {
566                        name: Some(CaptureName::parse("x").expect("an identifier")),
567                        parser: Box::new(atom(AtomicKind::Int)),
568                        span: Span::at(0),
569                        name_span: None,
570                    },
571                    TemplatePart::Capture {
572                        name: Some(CaptureName::parse("y").expect("an identifier")),
573                        parser: Box::new(atom(AtomicKind::Int)),
574                        span: Span::at(0),
575                        name_span: None,
576                    },
577                ],
578                span: Span::at(0),
579            }),
580            span: Span::at(0),
581        };
582        let t = synthesize(&ast, &mut db).expect("a valid AST synthesizes");
583        match db.data(t) {
584            praxis_typeck::TypeData::Collection { ctor, args } => {
585                assert_eq!(*ctor, CollectionCtor::Vec);
586                assert_eq!(args.len(), 1);
587                assert!(matches!(
588                    db.data(args[0]),
589                    praxis_typeck::TypeData::Record { .. }
590                ));
591            }
592            other => panic!("expected Vec[Record], got {other:?}"),
593        }
594        assert_eq!(db.render(t), "Vec[{ x: Int, y: Int }]");
595    }
596
597    /// **A counted group is a `Vec` field where it was written.** The unbounded
598    /// tail contributes the same `Vec[result(P)]`, but only ever at the end;
599    /// the record's field order is the source order of the named arguments, so
600    /// a fixed field after a counted group lands after it in the record too.
601    #[test]
602    fn a_counted_group_is_a_vec_field_in_the_position_it_was_written() {
603        use crate::ast::{RepeatCount, SectionItem};
604
605        let mut db = TypeDb::new();
606        let ast = ParserAst::SectionsNamed {
607            fields: vec![
608                SectionItem::Counted {
609                    name: "shapes".to_string(),
610                    count: RepeatCount::new(6).expect("six sections"),
611                    parser: ParserAst::Lines {
612                        child: Box::new(atom(AtomicKind::Int)),
613                        span: Span::at(0),
614                    },
615                },
616                SectionItem::One {
617                    name: "regions".to_string(),
618                    parser: ParserAst::Lines {
619                        child: Box::new(atom(AtomicKind::Int)),
620                        span: Span::at(0),
621                    },
622                },
623            ],
624            repeated_tail: None,
625            span: Span::at(0),
626        };
627        let t = synthesize(&ast, &mut db).expect("a valid AST synthesizes");
628        assert_eq!(db.render(t), "{ shapes: Vec[Vec[Int]], regions: Vec[Int] }");
629    }
630}