Skip to main content

synapse_parser/
ast.rs

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