Skip to main content

synapse_parser/
ast.rs

1use pest::{error::Error, iterators::Pair, Parser};
2
3use crate::synapse::{Rule, SynapseParser};
4
5// ── Public types ──────────────────────────────────────────────────────────────
6
7#[derive(Debug, Clone, PartialEq)]
8pub struct SynFile {
9    pub items: Vec<Item>,
10}
11
12#[derive(Debug, Clone, PartialEq)]
13pub enum Item {
14    Namespace(NamespaceDecl),
15    Import(ImportDecl),
16    Const(ConstDecl),
17    Enum(EnumDef),
18    Struct(StructDef),
19    Table(StructDef),
20    Command(MessageDef),
21    Telemetry(MessageDef),
22    Message(MessageDef),
23}
24
25#[derive(Debug, Clone, PartialEq)]
26pub struct NamespaceDecl {
27    pub name: ScopedIdent,
28}
29
30#[derive(Debug, Clone, PartialEq)]
31pub struct ImportDecl {
32    pub path: String,
33}
34
35#[derive(Debug, Clone, PartialEq)]
36pub struct ConstDecl {
37    pub name: String,
38    pub ty: TypeExpr,
39    pub value: Literal,
40    pub doc: Vec<String>,
41    pub attrs: Vec<Attribute>,
42}
43
44#[derive(Debug, Clone, PartialEq)]
45pub struct EnumDef {
46    pub name: String,
47    pub variants: Vec<EnumVariant>,
48    pub doc: Vec<String>,
49    pub attrs: Vec<Attribute>,
50}
51
52#[derive(Debug, Clone, PartialEq)]
53pub struct EnumVariant {
54    pub name: String,
55    pub value: Option<i64>,
56    pub doc: Vec<String>,
57}
58
59#[derive(Debug, Clone, PartialEq)]
60pub struct StructDef {
61    pub name: String,
62    pub fields: Vec<FieldDef>,
63    pub doc: Vec<String>,
64    pub attrs: Vec<Attribute>,
65}
66
67#[derive(Debug, Clone, PartialEq)]
68pub struct MessageDef {
69    pub kind: PacketKind,
70    pub name: String,
71    pub fields: Vec<FieldDef>,
72    pub doc: Vec<String>,
73    pub attrs: Vec<Attribute>,
74}
75
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77pub enum PacketKind {
78    /// Legacy generic Software Bus packet. cFS generators infer command vs telemetry.
79    Message,
80    /// cFS Software Bus command packet.
81    Command,
82    /// cFS Software Bus telemetry packet.
83    Telemetry,
84}
85
86#[derive(Debug, Clone, PartialEq)]
87pub struct FieldDef {
88    pub name: String,
89    pub optional: bool,
90    pub ty: TypeExpr,
91    pub default: Option<Literal>,
92    pub doc: Vec<String>,
93}
94
95#[derive(Debug, Clone, PartialEq)]
96pub struct TypeExpr {
97    pub base: BaseType,
98    pub array: Option<ArraySuffix>,
99}
100
101#[derive(Debug, Clone, PartialEq)]
102pub enum BaseType {
103    Primitive(PrimitiveType),
104    String,
105    Ref(ScopedIdent),
106}
107
108#[derive(Debug, Clone, Copy, PartialEq, Eq)]
109pub enum PrimitiveType {
110    F32,
111    F64,
112    I8,
113    I16,
114    I32,
115    I64,
116    U8,
117    U16,
118    U32,
119    U64,
120    Bool,
121    Bytes,
122}
123
124#[derive(Debug, Clone, PartialEq, Eq)]
125pub enum ArraySuffix {
126    /// `[]` — unbounded dynamic (Vec<T>)
127    Dynamic,
128    /// `[N]` — fixed size ([T; N])
129    Fixed(u64),
130    /// `[<=N]` — bounded dynamic (Vec<T> with max N)
131    Bounded(u64),
132}
133
134/// A namespace-qualified identifier, stored as individual segments.
135/// `geometry::Point` → `["geometry", "Point"]`
136pub type ScopedIdent = Vec<String>;
137
138#[derive(Debug, Clone, PartialEq)]
139pub enum Literal {
140    Float(f64),
141    Int(i64),
142    Hex(u64),
143    Bool(bool),
144    Str(String),
145    /// Enum variant or constant reference, e.g. `DriveMode::Idle`
146    Ident(ScopedIdent),
147}
148
149/// A declaration attribute, e.g. `@mid(0x0801)`.
150#[derive(Debug, Clone, PartialEq)]
151pub struct Attribute {
152    pub name: String,
153    pub value: Literal,
154}
155
156// ── Entry point ───────────────────────────────────────────────────────────────
157
158pub fn parse(input: &str) -> Result<SynFile, Error<Rule>> {
159    let file_pair = SynapseParser::parse(Rule::file, input)?.next().unwrap();
160    Ok(build_file(file_pair))
161}
162
163// ── Builders ──────────────────────────────────────────────────────────────────
164
165fn build_file(pair: Pair<Rule>) -> SynFile {
166    let items = pair
167        .into_inner()
168        .filter_map(|p| match p.as_rule() {
169            Rule::namespace_decl => Some(Item::Namespace(build_namespace(p))),
170            Rule::import_decl    => Some(Item::Import(build_import(p))),
171            Rule::const_decl     => Some(Item::Const(build_const(p))),
172            Rule::enum_def       => Some(Item::Enum(build_enum(p))),
173            Rule::struct_def     => Some(Item::Struct(build_struct(p))),
174            Rule::table_def      => Some(Item::Table(build_struct(p))),
175            Rule::command_def    => Some(Item::Command(build_packet(p, PacketKind::Command))),
176            Rule::telemetry_def  => Some(Item::Telemetry(build_packet(p, PacketKind::Telemetry))),
177            Rule::message_def    => Some(Item::Message(build_packet(p, PacketKind::Message))),
178            Rule::EOI            => None,
179            r                    => unreachable!("unexpected rule: {:?}", r),
180        })
181        .collect();
182    SynFile { items }
183}
184
185fn build_namespace(pair: Pair<Rule>) -> NamespaceDecl {
186    let scoped = pair.into_inner().next().unwrap();
187    NamespaceDecl { name: build_scoped_ident(scoped) }
188}
189
190fn build_import(pair: Pair<Rule>) -> ImportDecl {
191    let s = pair.into_inner().next().unwrap().as_str();
192    ImportDecl { path: s[1..s.len() - 1].to_string() }
193}
194
195fn build_const(pair: Pair<Rule>) -> ConstDecl {
196    let mut inner = pair.into_inner().peekable();
197    let doc   = extract_doc(&mut inner);
198    let attrs = extract_attrs(&mut inner);
199    let name  = inner.next().unwrap().as_str().to_string();
200    let ty    = build_type_expr(inner.next().unwrap());
201    let value = build_literal(inner.next().unwrap());
202    ConstDecl { name, ty, value, doc, attrs }
203}
204
205fn build_enum(pair: Pair<Rule>) -> EnumDef {
206    let mut inner = pair.into_inner().peekable();
207    let doc      = extract_doc(&mut inner);
208    let attrs    = extract_attrs(&mut inner);
209    let name     = inner.next().unwrap().as_str().to_string();
210    let variants = inner.map(build_enum_variant).collect();
211    EnumDef { name, variants, doc, attrs }
212}
213
214fn build_enum_variant(pair: Pair<Rule>) -> EnumVariant {
215    let mut inner = pair.into_inner().peekable();
216    let doc   = extract_doc(&mut inner);
217    let name  = inner.next().unwrap().as_str().to_string();
218    let value = inner.next().map(|p| p.as_str().parse::<i64>().unwrap());
219    EnumVariant { name, value, doc }
220}
221
222fn build_struct(pair: Pair<Rule>) -> StructDef {
223    let mut inner = pair.into_inner().peekable();
224    let doc    = extract_doc(&mut inner);
225    let attrs  = extract_attrs(&mut inner);
226    let name   = inner.next().unwrap().as_str().to_string();
227    let fields = inner.map(build_field).collect();
228    StructDef { name, fields, doc, attrs }
229}
230
231fn build_packet(pair: Pair<Rule>, kind: PacketKind) -> MessageDef {
232    let mut inner = pair.into_inner().peekable();
233    let doc    = extract_doc(&mut inner);
234    let attrs  = extract_attrs(&mut inner);
235    let name   = inner.next().unwrap().as_str().to_string();
236    let fields = inner.map(build_field).collect();
237    MessageDef { kind, name, fields, doc, attrs }
238}
239
240fn build_field(pair: Pair<Rule>) -> FieldDef {
241    let mut inner = pair.into_inner().peekable();
242    let doc  = extract_doc(&mut inner);
243    let name = inner.next().unwrap().as_str().to_string();
244
245    let next = inner.next().unwrap();
246    let (optional, type_pair) = if next.as_rule() == Rule::optional_marker {
247        (true, inner.next().unwrap())
248    } else {
249        (false, next)
250    };
251
252    let ty      = build_type_expr(type_pair);
253    let default = inner.next().map(build_literal);
254
255    FieldDef { name, optional, ty, default, doc }
256}
257
258/// Consume a leading `doc_block` (if present) and return the trimmed doc lines.
259fn extract_doc<'i>(
260    inner: &mut std::iter::Peekable<impl Iterator<Item = Pair<'i, Rule>>>,
261) -> Vec<String> {
262    if inner.peek().map(|p| p.as_rule()) == Some(Rule::doc_block) {
263        inner
264            .next()
265            .unwrap()
266            .into_inner()
267            .map(|p| p.as_str().strip_prefix("##").unwrap_or("").trim().to_string())
268            .collect()
269    } else {
270        vec![]
271    }
272}
273
274/// Consume zero or more leading `attribute` pairs and return them.
275fn extract_attrs<'i>(
276    inner: &mut std::iter::Peekable<impl Iterator<Item = Pair<'i, Rule>>>,
277) -> Vec<Attribute> {
278    let mut attrs = vec![];
279    while inner.peek().map(|p| p.as_rule()) == Some(Rule::attribute) {
280        let attr = inner.next().unwrap();
281        let mut ai = attr.into_inner();
282        let name  = ai.next().unwrap().as_str().to_string();
283        let value = build_literal(ai.next().unwrap());
284        attrs.push(Attribute { name, value });
285    }
286    attrs
287}
288
289fn build_type_expr(pair: Pair<Rule>) -> TypeExpr {
290    let mut inner = pair.into_inner();
291    let base  = build_base_type(inner.next().unwrap());
292    let array = inner.next().map(build_array_suffix);
293    TypeExpr { base, array }
294}
295
296fn build_base_type(pair: Pair<Rule>) -> BaseType {
297    let inner = pair.into_inner().next().unwrap();
298    match inner.as_rule() {
299        Rule::string_type    => BaseType::String,
300        Rule::primitive_type => BaseType::Primitive(build_primitive_type(inner)),
301        Rule::type_ref       => BaseType::Ref(build_scoped_ident(
302            inner.into_inner().next().unwrap(),
303        )),
304        r => unreachable!("unexpected base_type rule: {:?}", r),
305    }
306}
307
308fn build_primitive_type(pair: Pair<Rule>) -> PrimitiveType {
309    match pair.as_str() {
310        "f32"   => PrimitiveType::F32,
311        "f64"   => PrimitiveType::F64,
312        "i8"    => PrimitiveType::I8,
313        "i16"   => PrimitiveType::I16,
314        "i32"   => PrimitiveType::I32,
315        "i64"   => PrimitiveType::I64,
316        "u8"    => PrimitiveType::U8,
317        "u16"   => PrimitiveType::U16,
318        "u32"   => PrimitiveType::U32,
319        "u64"   => PrimitiveType::U64,
320        "bool"  => PrimitiveType::Bool,
321        "bytes" => PrimitiveType::Bytes,
322        s       => unreachable!("unknown primitive: {}", s),
323    }
324}
325
326fn build_array_suffix(pair: Pair<Rule>) -> ArraySuffix {
327    match pair.into_inner().next() {
328        None    => ArraySuffix::Dynamic,
329        Some(p) => {
330            let inner = p.into_inner().next().unwrap();
331            match inner.as_rule() {
332                Rule::bounded_size => {
333                    let n = inner
334                        .into_inner()
335                        .next()
336                        .unwrap()
337                        .as_str()
338                        .parse::<u64>()
339                        .unwrap();
340                    ArraySuffix::Bounded(n)
341                }
342                Rule::pos_int => ArraySuffix::Fixed(inner.as_str().parse::<u64>().unwrap()),
343                r => unreachable!("unexpected array_size rule: {:?}", r),
344            }
345        }
346    }
347}
348
349fn build_literal(pair: Pair<Rule>) -> Literal {
350    let inner = pair.into_inner().next().unwrap();
351    match inner.as_rule() {
352        Rule::float_lit  => Literal::Float(inner.as_str().parse::<f64>().unwrap()),
353        Rule::hex_lit    => {
354            let s = inner.as_str();
355            let digits = &s[2..]; // strip 0x / 0X
356            Literal::Hex(u64::from_str_radix(digits, 16).unwrap())
357        }
358        Rule::int_lit    => Literal::Int(inner.as_str().parse::<i64>().unwrap()),
359        Rule::bool_lit   => Literal::Bool(inner.as_str() == "true"),
360        Rule::string_lit => {
361            let s = inner.as_str();
362            Literal::Str(unescape(&s[1..s.len() - 1]))
363        }
364        Rule::ident_lit  => Literal::Ident(build_scoped_ident(
365            inner.into_inner().next().unwrap(),
366        )),
367        r => unreachable!("unexpected literal rule: {:?}", r),
368    }
369}
370
371fn build_scoped_ident(pair: Pair<Rule>) -> ScopedIdent {
372    pair.into_inner().map(|p| p.as_str().to_string()).collect()
373}
374
375fn unescape(s: &str) -> String {
376    let mut out = String::with_capacity(s.len());
377    let mut chars = s.chars();
378    while let Some(c) = chars.next() {
379        if c == '\\' {
380            match chars.next() {
381                Some('n')  => out.push('\n'),
382                Some('t')  => out.push('\t'),
383                Some('r')  => out.push('\r'),
384                Some('\\') => out.push('\\'),
385                Some('"')  => out.push('"'),
386                Some(c)    => { out.push('\\'); out.push(c); }
387                None       => out.push('\\'),
388            }
389        } else {
390            out.push(c);
391        }
392    }
393    out
394}
395
396// ── Tests ─────────────────────────────────────────────────────────────────────
397
398#[cfg(test)]
399mod tests {
400    use super::*;
401
402    fn p(input: &str) -> SynFile {
403        parse(input).expect("parse failed")
404    }
405
406    // ── Namespace ────────────────────────────────────────────
407
408    #[test]
409    fn namespace_simple() {
410        let f = p("namespace geometry");
411        assert_eq!(
412            f.items[0],
413            Item::Namespace(NamespaceDecl { name: vec!["geometry".into()] })
414        );
415    }
416
417    #[test]
418    fn namespace_qualified() {
419        let f = p("namespace nav::msgs");
420        assert_eq!(
421            f.items[0],
422            Item::Namespace(NamespaceDecl {
423                name: vec!["nav".into(), "msgs".into()]
424            })
425        );
426    }
427
428    // ── Import ───────────────────────────────────────────────
429
430    #[test]
431    fn import_path() {
432        let f = p(r#"import "geometry.syn""#);
433        assert_eq!(
434            f.items[0],
435            Item::Import(ImportDecl { path: "geometry.syn".into() })
436        );
437    }
438
439    // ── Const ────────────────────────────────────────────────
440
441    #[test]
442    fn const_float() {
443        let f = p("const PI: f64 = 3.14");
444        assert_eq!(
445            f.items[0],
446            Item::Const(ConstDecl {
447                name: "PI".into(),
448                ty: TypeExpr { base: BaseType::Primitive(PrimitiveType::F64), array: None },
449                value: Literal::Float(3.14),
450                doc: vec![], attrs: vec![],
451            })
452        );
453    }
454
455    #[test]
456    fn const_int() {
457        let f = p("const MAX: u32 = 256");
458        assert_eq!(
459            f.items[0],
460            Item::Const(ConstDecl {
461                name: "MAX".into(),
462                ty: TypeExpr { base: BaseType::Primitive(PrimitiveType::U32), array: None },
463                value: Literal::Int(256),
464                doc: vec![], attrs: vec![],
465            })
466        );
467    }
468
469    #[test]
470    fn const_string() {
471        let f = p(r#"const FRAME: string = "world""#);
472        assert_eq!(
473            f.items[0],
474            Item::Const(ConstDecl {
475                name: "FRAME".into(),
476                ty: TypeExpr { base: BaseType::String, array: None },
477                value: Literal::Str("world".into()),
478                doc: vec![], attrs: vec![],
479            })
480        );
481    }
482
483    // ── Enum ─────────────────────────────────────────────────
484
485    #[test]
486    fn enum_with_values() {
487        let f = p("enum DriveMode { Idle = 0  Forward = 1  Error = 2 }");
488        let Item::Enum(e) = &f.items[0] else { panic!() };
489        assert_eq!(e.name, "DriveMode");
490        assert_eq!(e.variants[0], EnumVariant { name: "Idle".into(),    value: Some(0), doc: vec![] });
491        assert_eq!(e.variants[1], EnumVariant { name: "Forward".into(), value: Some(1), doc: vec![] });
492        assert_eq!(e.variants[2], EnumVariant { name: "Error".into(),   value: Some(2), doc: vec![] });
493        assert!(e.attrs.is_empty());
494    }
495
496    #[test]
497    fn enum_without_values() {
498        let f = p("enum Dir { North South East West }");
499        let Item::Enum(e) = &f.items[0] else { panic!() };
500        assert!(e.variants.iter().all(|v| v.value.is_none()));
501        assert_eq!(e.variants.len(), 4);
502    }
503
504    // ── Struct ───────────────────────────────────────────────
505
506    #[test]
507    fn struct_basic() {
508        let f = p("struct Point { x: f64 = 0.0  y: f64 = 0.0  z: f64 = 0.0 }");
509        let Item::Struct(s) = &f.items[0] else { panic!() };
510        assert_eq!(s.name, "Point");
511        assert_eq!(s.fields.len(), 3);
512        assert_eq!(s.fields[0].name, "x");
513        assert_eq!(s.fields[0].ty.base, BaseType::Primitive(PrimitiveType::F64));
514        assert_eq!(s.fields[0].default, Some(Literal::Float(0.0)));
515        assert!(!s.fields[0].optional);
516    }
517
518    #[test]
519    fn struct_qualified_type() {
520        let f = p("struct Pose { position: geometry::Point  orientation: geometry::Quaternion }");
521        let Item::Struct(s) = &f.items[0] else { panic!() };
522        assert_eq!(
523            s.fields[0].ty.base,
524            BaseType::Ref(vec!["geometry".into(), "Point".into()])
525        );
526    }
527
528    // ── Message ──────────────────────────────────────────────
529
530    #[test]
531    fn message_optional_field() {
532        let f = p("message Foo { required: i32  optional?: string }");
533        let Item::Message(m) = &f.items[0] else { panic!() };
534        assert_eq!(m.kind, PacketKind::Message);
535        assert!(!m.fields[0].optional);
536        assert!(m.fields[1].optional);
537        assert_eq!(m.fields[1].ty.base, BaseType::String);
538    }
539
540    #[test]
541    fn command_packet_kind() {
542        let f = p("@mid(0x1880)\ncommand SetMode { mode: u8 }");
543        let Item::Command(m) = &f.items[0] else { panic!() };
544        assert_eq!(m.kind, PacketKind::Command);
545        assert_eq!(m.name, "SetMode");
546        assert_eq!(m.fields[0].name, "mode");
547    }
548
549    #[test]
550    fn telemetry_packet_kind() {
551        let f = p("@mid(0x0801)\ntelemetry NavState { x: f64 }");
552        let Item::Telemetry(m) = &f.items[0] else { panic!() };
553        assert_eq!(m.kind, PacketKind::Telemetry);
554        assert_eq!(m.name, "NavState");
555        assert_eq!(m.fields[0].name, "x");
556    }
557
558    #[test]
559    fn table_is_plain_data_item() {
560        let f = p("table NavConfig { max_speed: f64  enabled: bool }");
561        let Item::Table(t) = &f.items[0] else { panic!() };
562        assert_eq!(t.name, "NavConfig");
563        assert_eq!(t.fields.len(), 2);
564    }
565
566    #[test]
567    fn message_array_fields() {
568        let f = p("message D { dynamic: u8[]  fixed: f64[3]  bounded: u8[<=256] }");
569        let Item::Message(m) = &f.items[0] else { panic!() };
570        assert_eq!(m.fields[0].ty.array, Some(ArraySuffix::Dynamic));
571        assert_eq!(m.fields[1].ty.array, Some(ArraySuffix::Fixed(3)));
572        assert_eq!(m.fields[2].ty.array, Some(ArraySuffix::Bounded(256)));
573    }
574
575    #[test]
576    fn message_enum_default() {
577        let f = p("message S { mode: DriveMode = DriveMode::Idle }");
578        let Item::Message(m) = &f.items[0] else { panic!() };
579        assert_eq!(
580            m.fields[0].default,
581            Some(Literal::Ident(vec!["DriveMode".into(), "Idle".into()]))
582        );
583    }
584
585    #[test]
586    fn message_string_bounded() {
587        let f = p(r#"message S { label: string[<=64] = "robot" }"#);
588        let Item::Message(m) = &f.items[0] else { panic!() };
589        assert_eq!(m.fields[0].ty.base, BaseType::String);
590        assert_eq!(m.fields[0].ty.array, Some(ArraySuffix::Bounded(64)));
591        assert_eq!(m.fields[0].default, Some(Literal::Str("robot".into())));
592    }
593
594    // ── Hex literal ──────────────────────────────────────────
595
596    #[test]
597    fn hex_literal_const() {
598        let f = p("const MID: u16 = 0x0801");
599        let Item::Const(c) = &f.items[0] else { panic!() };
600        assert_eq!(c.value, Literal::Hex(0x0801));
601    }
602
603    #[test]
604    fn hex_literal_uppercase() {
605        let f = p("const MID: u16 = 0X1F80");
606        let Item::Const(c) = &f.items[0] else { panic!() };
607        assert_eq!(c.value, Literal::Hex(0x1F80));
608    }
609
610    // ── Attributes ───────────────────────────────────────────
611
612    #[test]
613    fn attribute_hex_on_message() {
614        let f = p("@mid(0x0801)\nmessage NavTlm { x: f64 }");
615        let Item::Message(m) = &f.items[0] else { panic!() };
616        assert_eq!(m.attrs.len(), 1);
617        assert_eq!(m.attrs[0].name, "mid");
618        assert_eq!(m.attrs[0].value, Literal::Hex(0x0801));
619    }
620
621    #[test]
622    fn attribute_ident_ref() {
623        let f = p("@mid(nav_app::NAV_TLM_MID)\nmessage NavTlm { x: f64 }");
624        let Item::Message(m) = &f.items[0] else { panic!() };
625        assert_eq!(m.attrs[0].value, Literal::Ident(vec!["nav_app".into(), "NAV_TLM_MID".into()]));
626    }
627
628    #[test]
629    fn no_attrs_is_empty() {
630        let f = p("message Foo { x: i32 }");
631        let Item::Message(m) = &f.items[0] else { panic!() };
632        assert!(m.attrs.is_empty());
633    }
634
635    // ── String escape sequences ───────────────────────────────
636
637    #[test]
638    fn string_escape_sequences() {
639        let f = p(r#"const S: string = "hello\nworld""#);
640        let Item::Const(c) = &f.items[0] else { panic!() };
641        assert_eq!(c.value, Literal::Str("hello\nworld".into()));
642    }
643
644    #[test]
645    fn string_escape_quote() {
646        let f = p(r#"const S: string = "say \"hi\"""#);
647        let Item::Const(c) = &f.items[0] else { panic!() };
648        assert_eq!(c.value, Literal::Str("say \"hi\"".into()));
649    }
650
651    // ── Full realistic file ───────────────────────────────────
652
653    #[test]
654    fn full_robot_state() {
655        let src = r#"
656            namespace robot
657            import "geometry.syn"
658
659            enum DriveMode {
660                Idle    = 0
661                Forward = 1
662                Error   = 2
663            }
664
665            const MAX_SPEED: f64 = 2.5
666
667            message RobotState {
668                mode:        DriveMode      = DriveMode::Idle
669                position:    geometry::Point
670                battery:     f32            = 100.0
671                label:       string[<=64]   = "robot"
672                sensor_data: u8[]
673                error_code?: i32
674            }
675        "#;
676
677        let f = parse(src).unwrap();
678        assert_eq!(f.items.len(), 5);
679
680        let Item::Namespace(ns) = &f.items[0] else { panic!() };
681        assert_eq!(ns.name, vec!["robot"]);
682
683        let Item::Enum(e) = &f.items[2] else { panic!() };
684        assert_eq!(e.variants.len(), 3);
685
686        let Item::Message(m) = &f.items[4] else { panic!() };
687        assert_eq!(m.name, "RobotState");
688        assert_eq!(m.fields.len(), 6);
689
690        // last field is optional
691        assert!(m.fields[5].optional);
692        assert_eq!(m.fields[5].name, "error_code");
693    }
694}