Skip to main content

haystack_core/xeto/
parser.rs

1// Xeto parser -- recursive descent parser producing AST nodes.
2
3use std::collections::HashMap;
4
5use crate::kinds::{Kind, Number};
6
7use super::XetoError;
8use super::ast::{LibPragma, SlotDef, SpecDef, XetoFile};
9use super::lexer::{TokenType, XetoLexer};
10
11/// Parse Xeto source text into an AST.
12pub fn parse_xeto(source: &str) -> Result<XetoFile, XetoError> {
13    let mut lexer = XetoLexer::new(source);
14    let tokens = lexer.tokenize()?;
15    let mut parser = Parser::new(tokens);
16    parser.parse_file()
17}
18
19// ---------------------------------------------------------------------------
20// Internal parser
21// ---------------------------------------------------------------------------
22
23struct Parser {
24    tokens: Vec<super::lexer::Token>,
25    pos: usize,
26}
27
28impl Parser {
29    fn new(tokens: Vec<super::lexer::Token>) -> Self {
30        Self { tokens, pos: 0 }
31    }
32
33    // -- peek / advance / expect helpers --
34
35    fn peek(&self) -> &super::lexer::Token {
36        &self.tokens[self.pos]
37    }
38
39    fn peek_type(&self) -> &TokenType {
40        &self.tokens[self.pos].typ
41    }
42
43    fn at_end(&self) -> bool {
44        self.peek_type() == &TokenType::Eof
45    }
46
47    fn advance(&mut self) -> &super::lexer::Token {
48        let tok = &self.tokens[self.pos];
49        if tok.typ != TokenType::Eof {
50            self.pos += 1;
51        }
52        tok
53    }
54
55    fn expect(&mut self, typ: TokenType) -> Result<&super::lexer::Token, XetoError> {
56        let tok = &self.tokens[self.pos];
57        if tok.typ != typ {
58            return Err(XetoError::Parse {
59                line: tok.line,
60                col: tok.col,
61                message: format!("expected {:?}, got {:?} ('{}')", typ, tok.typ, tok.val),
62            });
63        }
64        Ok(self.advance())
65    }
66
67    fn skip_newlines(&mut self) {
68        while self.peek_type() == &TokenType::Newline {
69            self.advance();
70        }
71    }
72
73    /// Collect consecutive comment tokens as doc text.
74    /// Skips section separators (lines of all slashes like `////`).
75    fn collect_doc(&mut self) -> String {
76        let mut lines: Vec<String> = Vec::new();
77        loop {
78            // Peek for comment tokens, skip newlines between comments
79            if *self.peek_type() == TokenType::Comment {
80                let val = self.peek().val.clone();
81                self.advance();
82                // Skip section separators: lines of just slashes
83                let trimmed = val.trim();
84                if !trimmed.is_empty() && trimmed.chars().all(|c| c == '/') {
85                    // This is a separator like "////" -- skip it
86                    // Also skip the newline after it
87                    if *self.peek_type() == TokenType::Newline {
88                        self.advance();
89                    }
90                    continue;
91                }
92                lines.push(val);
93                // Skip newline after comment
94                if *self.peek_type() == TokenType::Newline {
95                    self.advance();
96                }
97            } else {
98                break;
99            }
100        }
101        lines.join("\n")
102    }
103
104    // -- grammar rules --
105
106    /// File := Pragma? Spec*
107    fn parse_file(&mut self) -> Result<XetoFile, XetoError> {
108        self.skip_newlines();
109
110        let pragma = if *self.peek_type() == TokenType::Ident && self.peek().val == "pragma" {
111            Some(self.parse_pragma()?)
112        } else {
113            None
114        };
115
116        let mut specs = Vec::new();
117        loop {
118            self.skip_newlines();
119            if self.at_end() {
120                break;
121            }
122
123            // Collect doc comments
124            let doc = self.collect_doc();
125            self.skip_newlines();
126            if self.at_end() {
127                break;
128            }
129
130            let mut spec = self.parse_spec()?;
131            if !doc.is_empty() && spec.doc.is_empty() {
132                spec.doc = doc;
133            }
134            specs.push(spec);
135        }
136
137        Ok(XetoFile { pragma, specs })
138    }
139
140    /// Pragma := "pragma" ":" "Lib" Meta
141    fn parse_pragma(&mut self) -> Result<LibPragma, XetoError> {
142        self.expect(TokenType::Ident)?; // "pragma"
143        self.expect(TokenType::Colon)?;
144        self.expect(TokenType::Ident)?; // "Lib"
145        self.skip_newlines();
146
147        let meta = if *self.peek_type() == TokenType::LAngle {
148            self.parse_meta()?
149        } else {
150            HashMap::new()
151        };
152
153        // Extract known fields from meta
154        let name = match meta.get("name") {
155            Some(Kind::Str(s)) => s.clone(),
156            _ => String::new(),
157        };
158        let version = match meta.get("version") {
159            Some(Kind::Str(s)) => s.clone(),
160            _ => String::new(),
161        };
162        let doc = match meta.get("doc") {
163            Some(Kind::Str(s)) => s.clone(),
164            _ => String::new(),
165        };
166
167        // Parse depends list -- supports both string lists and dict-based format
168        let depends = match meta.get("depends") {
169            Some(Kind::List(items)) => items
170                .iter()
171                .filter_map(|k| match k {
172                    Kind::Str(s) => Some(s.clone()),
173                    Kind::Dict(d) => {
174                        if let Some(Kind::Str(lib_name)) = d.get("lib") {
175                            Some(lib_name.clone())
176                        } else {
177                            None
178                        }
179                    }
180                    _ => None,
181                })
182                .collect(),
183            Some(Kind::Dict(d)) => {
184                // Single dict depends entry
185                if let Some(Kind::Str(lib_name)) = d.get("lib") {
186                    vec![lib_name.clone()]
187                } else {
188                    Vec::new()
189                }
190            }
191            Some(Kind::Str(dep)) => vec![dep.clone()],
192            _ => Vec::new(),
193        };
194
195        Ok(LibPragma {
196            name,
197            version,
198            doc,
199            depends,
200            meta,
201        })
202    }
203
204    /// Spec := Name (":" TypeRef)? Meta? Default? Body?
205    fn parse_spec(&mut self) -> Result<SpecDef, XetoError> {
206        let name = self.parse_dotted_name()?;
207        let mut spec = SpecDef::new(name);
208
209        self.skip_newlines();
210
211        // Optional `:` TypeRef
212        if *self.peek_type() == TokenType::Colon {
213            self.advance();
214            self.skip_newlines();
215            // Guard: if next is <, there's no base type, just meta
216            if *self.peek_type() != TokenType::LAngle {
217                spec.base = Some(self.parse_type_ref()?);
218                self.skip_newlines();
219            }
220        }
221
222        // Optional Meta
223        if *self.peek_type() == TokenType::LAngle {
224            spec.meta = self.parse_meta()?;
225            self.skip_newlines();
226        }
227
228        // Optional Default
229        if *self.peek_type() == TokenType::Str || *self.peek_type() == TokenType::Number {
230            spec.default = Some(self.parse_value()?);
231            self.skip_newlines();
232        }
233
234        // Optional Body
235        if *self.peek_type() == TokenType::LBrace {
236            spec.slots = self.parse_body()?;
237        }
238
239        Ok(spec)
240    }
241
242    /// Body := "{" Slot* "}"
243    fn parse_body(&mut self) -> Result<Vec<SlotDef>, XetoError> {
244        self.expect(TokenType::LBrace)?;
245        self.skip_newlines();
246
247        let mut slots = Vec::new();
248        while *self.peek_type() != TokenType::RBrace && !self.at_end() {
249            // Collect doc comments for slot
250            let doc = self.collect_doc();
251            self.skip_newlines();
252
253            if *self.peek_type() == TokenType::RBrace {
254                break;
255            }
256
257            let mut slot = self.parse_slot()?;
258            if !doc.is_empty() && slot.doc.is_empty() {
259                slot.doc = doc;
260            }
261            slots.push(slot);
262            self.skip_newlines();
263        }
264
265        self.expect(TokenType::RBrace)?;
266        Ok(slots)
267    }
268
269    /// Slot := "*"? Name (":" TypeRef Meta? Body? Default?)?
270    ///
271    /// A bare name with no colon is treated as a marker slot.
272    fn parse_slot(&mut self) -> Result<SlotDef, XetoError> {
273        let is_global = if *self.peek_type() == TokenType::Star {
274            self.advance();
275            self.skip_newlines();
276            true
277        } else {
278            false
279        };
280
281        let name = self.parse_dotted_name()?;
282        let mut slot = SlotDef::new(name);
283        slot.is_global = is_global;
284
285        self.skip_newlines();
286
287        // Check for colon -> typed slot
288        if *self.peek_type() == TokenType::Colon {
289            self.advance();
290            self.skip_newlines();
291
292            let type_ref = self.parse_type_ref()?;
293
294            // Check for Query type
295            if type_ref == "Query" {
296                slot.is_query = true;
297                // Parse query parameters from meta
298                if *self.peek_type() == TokenType::LAngle {
299                    let query_meta = self.parse_meta()?;
300                    if let Some(Kind::Str(s)) = query_meta.get("of") {
301                        slot.query_of = Some(s.clone());
302                    }
303                    // Also handle "of" as a bare ident value
304                    if slot.query_of.is_none()
305                        && let Some(Kind::Marker) = query_meta.get("of")
306                    {
307                        // "of" was parsed as marker (bare ident); we need
308                        // the actual ident value. This is handled by type
309                        // ref parsing in meta.
310                    }
311                    if let Some(Kind::Str(s)) = query_meta.get("via") {
312                        slot.query_via = Some(s.clone());
313                    }
314                    if let Some(Kind::Str(s)) = query_meta.get("inverse") {
315                        slot.query_inverse = Some(s.clone());
316                    }
317                    slot.meta = query_meta;
318                }
319            } else {
320                slot.type_ref = Some(type_ref);
321            }
322
323            // Maybe suffix (?)
324            self.skip_newlines();
325            if *self.peek_type() == TokenType::Question {
326                self.advance();
327                slot.is_maybe = true;
328                slot.meta.insert("maybe".to_string(), Kind::Marker);
329            }
330
331            // Additional meta
332            self.skip_newlines();
333            if *self.peek_type() == TokenType::LAngle {
334                let extra_meta = self.parse_meta()?;
335                for (k, v) in extra_meta {
336                    slot.meta.insert(k, v);
337                }
338            }
339
340            // Maybe suffix after <...> params (e.g. Ref<of:Spec>?)
341            self.skip_newlines();
342            if !slot.is_maybe && *self.peek_type() == TokenType::Question {
343                self.advance();
344                slot.is_maybe = true;
345                slot.meta.insert("maybe".to_string(), Kind::Marker);
346            }
347
348            // Body
349            self.skip_newlines();
350            if *self.peek_type() == TokenType::LBrace {
351                slot.children = self.parse_body()?;
352            }
353
354            // Default
355            self.skip_newlines();
356            if *self.peek_type() == TokenType::Str || *self.peek_type() == TokenType::Number {
357                slot.default = Some(self.parse_value()?);
358            }
359        } else {
360            // Bare name = marker slot
361            slot.is_marker = true;
362
363            // A marker slot can also have a ? suffix for maybe
364            if *self.peek_type() == TokenType::Question {
365                self.advance();
366                slot.is_maybe = true;
367                slot.meta.insert("maybe".to_string(), Kind::Marker);
368            }
369        }
370
371        Ok(slot)
372    }
373
374    /// TypeRef := Ident ("." Ident)* ("::" Ident)? ("?" )?
375    ///
376    /// Returns the assembled type reference string.
377    fn parse_type_ref(&mut self) -> Result<String, XetoError> {
378        let mut name = String::new();
379
380        // First ident
381        let tok = self.expect(TokenType::Ident)?;
382        name.push_str(&tok.val.clone());
383
384        // Dotted parts
385        while *self.peek_type() == TokenType::Dot {
386            self.advance();
387            let part = self.expect(TokenType::Ident)?;
388            name.push('.');
389            name.push_str(&part.val.clone());
390        }
391
392        // Qualified name (::)
393        if *self.peek_type() == TokenType::ColonColon {
394            self.advance();
395            let part = self.expect(TokenType::Ident)?;
396            name.push_str("::");
397            name.push_str(&part.val.clone());
398        }
399
400        Ok(name)
401    }
402
403    /// Meta := "<" MetaTag ("," MetaTag)* ">"
404    fn parse_meta(&mut self) -> Result<HashMap<String, Kind>, XetoError> {
405        self.expect(TokenType::LAngle)?;
406        self.skip_newlines();
407
408        let mut meta = HashMap::new();
409
410        while *self.peek_type() != TokenType::RAngle && !self.at_end() {
411            self.skip_newlines();
412            if *self.peek_type() == TokenType::RAngle {
413                break;
414            }
415
416            let tag_name = self.expect(TokenType::Ident)?;
417            let tag_name = tag_name.val.clone();
418            self.skip_newlines();
419
420            if *self.peek_type() == TokenType::Colon {
421                self.advance();
422                self.skip_newlines();
423                let value = self.parse_meta_value()?;
424                meta.insert(tag_name, value);
425            } else {
426                // Bare tag = marker
427                meta.insert(tag_name, Kind::Marker);
428            }
429
430            self.skip_newlines();
431
432            // Optional comma separator
433            if *self.peek_type() == TokenType::Comma {
434                self.advance();
435                self.skip_newlines();
436            }
437        }
438
439        self.expect(TokenType::RAngle)?;
440        Ok(meta)
441    }
442
443    /// Parse a meta value (string, number, ident, or list).
444    fn parse_meta_value(&mut self) -> Result<Kind, XetoError> {
445        match self.peek_type().clone() {
446            TokenType::Str => {
447                let tok = self.advance();
448                Ok(Kind::Str(tok.val.clone()))
449            }
450            TokenType::Number => {
451                let tok = self.advance();
452                let val = tok.val.clone();
453                Self::parse_number_val(&val)
454            }
455            TokenType::Ident => {
456                let tok = self.advance();
457                let val = tok.val.clone();
458                // Handle dotted names as string values
459                let mut full_name = val;
460                while *self.peek_type() == TokenType::Dot {
461                    self.advance();
462                    let part = self.expect(TokenType::Ident)?;
463                    full_name.push('.');
464                    full_name.push_str(&part.val.clone());
465                }
466                // Handle qualified names
467                if *self.peek_type() == TokenType::ColonColon {
468                    self.advance();
469                    let part = self.expect(TokenType::Ident)?;
470                    full_name.push_str("::");
471                    full_name.push_str(&part.val.clone());
472                }
473                // Check for parameterized type: Ident<...>
474                if *self.peek_type() == TokenType::LAngle {
475                    let inner_meta = self.parse_meta()?;
476                    let parts: Vec<String> = inner_meta
477                        .iter()
478                        .map(|(k, v)| format!("{}:{}", k, v))
479                        .collect();
480                    Ok(Kind::Str(format!("{}<{}>", full_name, parts.join(","))))
481                } else {
482                    Ok(Kind::Str(full_name))
483                }
484            }
485            TokenType::LBrace => {
486                // Parse a dict or list-of-dicts value
487                self.advance(); // consume {
488                self.skip_newlines();
489                let mut items: Vec<Kind> = Vec::new();
490                while *self.peek_type() != TokenType::RBrace && *self.peek_type() != TokenType::Eof
491                {
492                    if *self.peek_type() == TokenType::LBrace {
493                        // Nested dict: { key: val, ... }
494                        self.advance(); // consume inner {
495                        self.skip_newlines();
496                        let mut dict = crate::data::HDict::new();
497                        while *self.peek_type() != TokenType::RBrace
498                            && *self.peek_type() != TokenType::Eof
499                        {
500                            let key = self.expect(TokenType::Ident)?.val.clone();
501                            self.expect(TokenType::Colon)?;
502                            self.skip_newlines();
503                            let val = self.parse_meta_value()?;
504                            dict.set(&key, val);
505                            self.skip_newlines();
506                            if *self.peek_type() == TokenType::Comma {
507                                self.advance();
508                                self.skip_newlines();
509                            }
510                        }
511                        self.expect(TokenType::RBrace)?;
512                        items.push(Kind::Dict(Box::new(dict)));
513                        self.skip_newlines();
514                    } else {
515                        // Key-value pair at top level: key: val
516                        let _key = self.expect(TokenType::Ident)?.val.clone();
517                        self.expect(TokenType::Colon)?;
518                        self.skip_newlines();
519                        let val = self.parse_meta_value()?;
520                        items.push(val);
521                        self.skip_newlines();
522                        if *self.peek_type() == TokenType::Comma {
523                            self.advance();
524                            self.skip_newlines();
525                        }
526                    }
527                }
528                self.expect(TokenType::RBrace)?;
529                if items.len() == 1 {
530                    Ok(items.into_iter().next().unwrap())
531                } else {
532                    Ok(Kind::List(items))
533                }
534            }
535            _ => {
536                let tok = self.peek();
537                Err(XetoError::Parse {
538                    line: tok.line,
539                    col: tok.col,
540                    message: format!("expected meta value, got {:?}", tok.typ),
541                })
542            }
543        }
544    }
545
546    /// Parse a value literal (string or number).
547    fn parse_value(&mut self) -> Result<Kind, XetoError> {
548        match self.peek_type().clone() {
549            TokenType::Str => {
550                let tok = self.advance();
551                Ok(Kind::Str(tok.val.clone()))
552            }
553            TokenType::Number => {
554                let tok = self.advance();
555                Self::parse_number_val(&tok.val.clone())
556            }
557            _ => {
558                let tok = self.peek();
559                Err(XetoError::Parse {
560                    line: tok.line,
561                    col: tok.col,
562                    message: format!("expected value literal, got {:?}", tok.typ),
563                })
564            }
565        }
566    }
567
568    /// Parse a dotted name: Ident ("." Ident)*
569    fn parse_dotted_name(&mut self) -> Result<String, XetoError> {
570        let tok = self.expect(TokenType::Ident)?;
571        let mut name = tok.val.clone();
572
573        while *self.peek_type() == TokenType::Dot {
574            self.advance();
575            let part = self.expect(TokenType::Ident)?;
576            name.push('.');
577            name.push_str(&part.val.clone());
578        }
579
580        // Also handle qualified names at the top level
581        if *self.peek_type() == TokenType::ColonColon {
582            self.advance();
583            let part = self.expect(TokenType::Ident)?;
584            name.push_str("::");
585            name.push_str(&part.val.clone());
586        }
587
588        Ok(name)
589    }
590
591    /// Parse a numeric string into a Kind::Number.
592    fn parse_number_val(text: &str) -> Result<Kind, XetoError> {
593        // Split numeric part from unit suffix.
594        // The numeric part may include digits, '.', '-', and exponent notation (e/E followed
595        // by optional +/- and digits).
596        let bytes = text.as_bytes();
597        let mut i = 0;
598        // Leading minus
599        if i < bytes.len() && bytes[i] == b'-' {
600            i += 1;
601        }
602        // Integer digits
603        while i < bytes.len() && bytes[i].is_ascii_digit() {
604            i += 1;
605        }
606        // Fractional part
607        if i < bytes.len() && bytes[i] == b'.' {
608            i += 1;
609            while i < bytes.len() && bytes[i].is_ascii_digit() {
610                i += 1;
611            }
612        }
613        // Exponent part
614        if i < bytes.len() && (bytes[i] == b'e' || bytes[i] == b'E') {
615            i += 1;
616            if i < bytes.len() && (bytes[i] == b'+' || bytes[i] == b'-') {
617                i += 1;
618            }
619            while i < bytes.len() && bytes[i].is_ascii_digit() {
620                i += 1;
621            }
622        }
623        let (num_str, unit_str) = text.split_at(i);
624        let val: f64 = num_str.parse().map_err(|_| XetoError::Parse {
625            line: 0,
626            col: 0,
627            message: format!("invalid number: {}", text),
628        })?;
629        let unit = if unit_str.is_empty() {
630            None
631        } else {
632            Some(unit_str.to_string())
633        };
634        Ok(Kind::Number(Number::new(val, unit)))
635    }
636}
637
638#[cfg(test)]
639mod tests {
640    use super::*;
641
642    #[test]
643    fn parse_empty_file() {
644        let file = parse_xeto("").unwrap();
645        assert!(file.pragma.is_none());
646        assert!(file.specs.is_empty());
647    }
648
649    #[test]
650    fn parse_simple_spec() {
651        let file = parse_xeto("Ahu : Equip {\n  discharge\n  return\n}").unwrap();
652        assert_eq!(file.specs.len(), 1);
653        let spec = &file.specs[0];
654        assert_eq!(spec.name, "Ahu");
655        assert_eq!(spec.base.as_deref(), Some("Equip"));
656        assert_eq!(spec.slots.len(), 2);
657        assert_eq!(spec.slots[0].name, "discharge");
658        assert!(spec.slots[0].is_marker);
659        assert_eq!(spec.slots[1].name, "return");
660        assert!(spec.slots[1].is_marker);
661    }
662
663    #[test]
664    fn parse_spec_with_meta() {
665        let file = parse_xeto("Ahu : Equip <abstract> {\n  discharge\n}").unwrap();
666        let spec = &file.specs[0];
667        assert!(spec.meta.contains_key("abstract"));
668        assert_eq!(spec.meta.get("abstract"), Some(&Kind::Marker));
669    }
670
671    #[test]
672    fn parse_typed_slots() {
673        let file = parse_xeto("Site : Entity {\n  dis : Str\n  area : Number\n}").unwrap();
674        let spec = &file.specs[0];
675        assert_eq!(spec.slots.len(), 2);
676        assert_eq!(spec.slots[0].name, "dis");
677        assert_eq!(spec.slots[0].type_ref.as_deref(), Some("Str"));
678        assert!(!spec.slots[0].is_marker);
679        assert_eq!(spec.slots[1].name, "area");
680        assert_eq!(spec.slots[1].type_ref.as_deref(), Some("Number"));
681    }
682
683    #[test]
684    fn parse_marker_slots() {
685        let file = parse_xeto("Ahu : Equip {\n  hot\n  cold\n}").unwrap();
686        let spec = &file.specs[0];
687        assert!(spec.slots[0].is_marker);
688        assert!(spec.slots[1].is_marker);
689    }
690
691    #[test]
692    fn parse_maybe_slots() {
693        let file = parse_xeto("Foo : Bar {\n  name : Str?\n}").unwrap();
694        let slot = &file.specs[0].slots[0];
695        assert_eq!(slot.name, "name");
696        assert_eq!(slot.type_ref.as_deref(), Some("Str"));
697        assert!(slot.is_maybe);
698        assert!(slot.meta.contains_key("maybe"));
699    }
700
701    #[test]
702    fn parse_query_slots() {
703        let file = parse_xeto("Ahu : Equip {\n  points : Query<of:\"Point\", via:\"equipRef\">\n}")
704            .unwrap();
705        let slot = &file.specs[0].slots[0];
706        assert_eq!(slot.name, "points");
707        assert!(slot.is_query);
708        assert_eq!(slot.query_of.as_deref(), Some("Point"));
709        assert_eq!(slot.query_via.as_deref(), Some("equipRef"));
710    }
711
712    #[test]
713    fn parse_pragma() {
714        let src = r#"pragma : Lib <
715  name: "acme",
716  version: "1.0.0",
717  doc: "My lib"
718>
719Foo : Bar
720"#;
721        let file = parse_xeto(src).unwrap();
722        let pragma = file.pragma.as_ref().unwrap();
723        assert_eq!(pragma.name, "acme");
724        assert_eq!(pragma.version, "1.0.0");
725        assert_eq!(pragma.doc, "My lib");
726    }
727
728    #[test]
729    fn parse_dotted_type_refs() {
730        let file = parse_xeto("Ahu : ph.equips.Equip").unwrap();
731        let spec = &file.specs[0];
732        assert_eq!(spec.base.as_deref(), Some("ph.equips.Equip"));
733    }
734
735    #[test]
736    fn parse_qualified_type_refs() {
737        let file = parse_xeto("Ahu : ph::Equip").unwrap();
738        let spec = &file.specs[0];
739        assert_eq!(spec.base.as_deref(), Some("ph::Equip"));
740    }
741
742    #[test]
743    fn parse_defaults() {
744        let file = parse_xeto("Foo : Str \"hello\"").unwrap();
745        let spec = &file.specs[0];
746        assert_eq!(spec.default, Some(Kind::Str("hello".to_string())));
747    }
748
749    #[test]
750    fn parse_number_defaults() {
751        let file = parse_xeto("Foo : Number {\n  val : Number 72.5\n}").unwrap();
752        let slot = &file.specs[0].slots[0];
753        assert_eq!(slot.default, Some(Kind::Number(Number::unitless(72.5))));
754    }
755
756    #[test]
757    fn parse_doc_comments() {
758        let src = "// This is an AHU\n// It handles air\nAhu : Equip";
759        let file = parse_xeto(src).unwrap();
760        let spec = &file.specs[0];
761        assert_eq!(spec.doc, "This is an AHU\nIt handles air");
762    }
763
764    #[test]
765    fn parse_section_separator_comments() {
766        let src = "////\n// Equips\n////\nAhu : Equip";
767        let file = parse_xeto(src).unwrap();
768        assert_eq!(file.specs.len(), 1);
769        assert_eq!(file.specs[0].name, "Ahu");
770        assert_eq!(file.specs[0].doc, "Equips");
771    }
772
773    #[test]
774    fn parse_multiple_specs() {
775        let src = "Ahu : Equip\nVav : Equip";
776        let file = parse_xeto(src).unwrap();
777        assert_eq!(file.specs.len(), 2);
778        assert_eq!(file.specs[0].name, "Ahu");
779        assert_eq!(file.specs[1].name, "Vav");
780    }
781
782    #[test]
783    fn parse_global_slots() {
784        let file = parse_xeto("Foo : Bar {\n  *name : Str\n}").unwrap();
785        let slot = &file.specs[0].slots[0];
786        assert!(slot.is_global);
787        assert_eq!(slot.name, "name");
788    }
789
790    #[test]
791    fn parse_maybe_marker_slot() {
792        let file = parse_xeto("Foo : Bar {\n  optional?\n}").unwrap();
793        let slot = &file.specs[0].slots[0];
794        assert!(slot.is_marker);
795        assert!(slot.is_maybe);
796    }
797
798    #[test]
799    fn parse_meta_with_typed_value() {
800        let src = r#"Foo : Bar <doc: "A foo", maxVal: 100>"#;
801        let file = parse_xeto(src).unwrap();
802        let spec = &file.specs[0];
803        assert_eq!(spec.meta.get("doc"), Some(&Kind::Str("A foo".to_string())));
804        assert_eq!(
805            spec.meta.get("maxVal"),
806            Some(&Kind::Number(Number::unitless(100.0)))
807        );
808    }
809
810    #[test]
811    fn parse_nested_body() {
812        let src = "Ahu : Equip {\n  points : Query {\n    temp : Point\n  }\n}";
813        let file = parse_xeto(src).unwrap();
814        let slot = &file.specs[0].slots[0];
815        assert_eq!(slot.name, "points");
816        assert_eq!(slot.children.len(), 1);
817        assert_eq!(slot.children[0].name, "temp");
818    }
819
820    #[test]
821    fn parse_spec_no_base() {
822        let file = parse_xeto("Foo {\n  bar\n}").unwrap();
823        let spec = &file.specs[0];
824        assert_eq!(spec.name, "Foo");
825        assert!(spec.base.is_none());
826        assert_eq!(spec.slots.len(), 1);
827    }
828
829    #[test]
830    fn parse_slot_doc_comments() {
831        let src = "Foo : Bar {\n  // The name\n  name : Str\n}";
832        let file = parse_xeto(src).unwrap();
833        let slot = &file.specs[0].slots[0];
834        assert_eq!(slot.doc, "The name");
835    }
836
837    #[test]
838    fn spec_colon_meta_no_base() {
839        let file = parse_xeto("Obj : <sealed> {}").unwrap();
840        let spec = &file.specs[0];
841        assert_eq!(spec.name, "Obj");
842        assert!(spec.base.is_none());
843        assert!(spec.meta.contains_key("sealed"));
844    }
845
846    #[test]
847    fn meta_dict_value() {
848        let src = r#"pragma : Lib <depends: { { lib: "ph" } }>"#;
849        let file = parse_xeto(src).unwrap();
850        let pragma = file.pragma.as_ref().unwrap();
851        assert_eq!(pragma.depends, vec!["ph"]);
852    }
853
854    #[test]
855    fn meta_parameterized_type() {
856        let src = r#"Foo : Bar <type: Ref<of:Spec>>"#;
857        let file = parse_xeto(src).unwrap();
858        let spec = &file.specs[0];
859        let type_val = spec.meta.get("type").unwrap();
860        if let Kind::Str(s) = type_val {
861            assert!(s.starts_with("Ref<"));
862            assert!(s.contains("of:"));
863            assert!(s.contains("Spec"));
864            assert!(s.ends_with(">"));
865        } else {
866            panic!("expected Str, got {:?}", type_val);
867        }
868    }
869
870    #[test]
871    fn slot_maybe_after_params() {
872        // ? before <...>
873        let file = parse_xeto("Foo : Bar {\n  dis : Str?\n}").unwrap();
874        let slot = &file.specs[0].slots[0];
875        assert!(slot.is_maybe);
876
877        // ? after <...> meta
878        let file2 = parse_xeto("Foo : Bar {\n  link : Ref <of: Equip>?\n}").unwrap();
879        let slot2 = &file2.specs[0].slots[0];
880        assert!(slot2.is_maybe);
881    }
882}