Skip to main content

kdl_codegen/parser/
mod.rs

1//! Parser — KDL schema file → [`crate::ir::Schema`].
2//!
3//! Parsing happens in two stages:
4//!
5//! 1. **deserialize** — `club-kdl`'s `KdlDeserialize` derive fills the
6//!    KDL-shaped `raw` structs from the document.
7//! 2. **lower** — `raw` structs are converted into the validated
8//!    [`crate::ir`] representation: enum-like strings become real enums, the
9//!    flat `type` string becomes a [`crate::ir::Ty`], and channel semantics
10//!    (datagram `channel_id` requirements) are checked.
11//!
12//! Only the modern dialect is accepted — `protocol` / `channel` / `request` /
13//! `returns` / `event` / `field`, standalone `struct` / `enum`, and the
14//! entity dialect `record` / `relation` / `id`. Legacy `service` / `method` /
15//! `send` / `recv` constructs are not parsed.
16
17mod raw;
18
19use std::collections::HashSet;
20use std::path::Path;
21
22use crate::ir;
23
24/// An error produced while parsing a KDL schema.
25///
26/// Marked `#[non_exhaustive]` so future error sources (network fetches, new
27/// dialect-specific validation passes, …) can be added without a major bump.
28/// Downstream `match` blocks need a `_ => …` arm.
29#[derive(Debug, thiserror::Error)]
30#[non_exhaustive]
31pub enum ParseError {
32    /// The input was not well-formed KDL, or did not match the schema shape.
33    #[error("KDL parse error: {0}")]
34    Kdl(String),
35    /// The input parsed as KDL but violated a schema rule.
36    #[error("schema validation error: {0}")]
37    Validation(String),
38    /// A `(<)file` / `(<)glob` directive failed to resolve. Only produced by
39    /// [`parse_path`] — the pure-text [`parse`] and [`parse_doc`] entries
40    /// never read the filesystem and therefore cannot emit this variant.
41    #[error("compose error: {0}")]
42    Compose(#[from] kdl_compose::ComposeError),
43}
44
45/// Parse a KDL schema source string into a [`Schema`](ir::Schema). Pure text
46/// in, pure value out — no filesystem access, no directive resolution.
47///
48/// # Errors
49///
50/// Returns [`ParseError::Kdl`] if the input is not well-formed KDL or does not
51/// match the schema shape, and [`ParseError::Validation`] if it parses but
52/// breaks a schema rule (e.g. a datagram channel without a `channel_id`).
53pub fn parse(src: &str) -> Result<ir::Schema, ParseError> {
54    let raw: raw::RawSchema =
55        club_kdl::from_str(src).map_err(|e| ParseError::Kdl(e.to_string()))?;
56    lower_schema(raw)
57}
58
59/// Parse an already-loaded [`kdl::KdlDocument`] into a [`Schema`](ir::Schema).
60///
61/// The intended caller is [`parse_path`], which composes a multi-file schema
62/// via [`kdl_compose::compose`] and then hands the resolved document to
63/// this entry; exposed publicly so library consumers who build a `KdlDocument`
64/// some other way (custom IO, in-memory generation) can skip the string round
65/// trip.
66///
67/// # Errors
68///
69/// Same shape as [`parse`] — [`ParseError::Kdl`] on a deserialization failure,
70/// [`ParseError::Validation`] on a schema-rule violation.
71pub fn parse_doc(doc: &kdl::KdlDocument) -> Result<ir::Schema, ParseError> {
72    let raw: raw::RawSchema =
73        club_kdl::from_doc(doc).map_err(|e| ParseError::Kdl(e.to_string()))?;
74    lower_schema(raw)
75}
76
77/// Read a KDL schema file from disk, resolve every `(<)file` / `(<)glob`
78/// directive in it via [`kdl_compose::compose`], then parse the composed
79/// document into a [`Schema`](ir::Schema).
80///
81/// A schema that contains no directives is composed to a document
82/// byte-equivalent to the on-disk file, so this entry is a drop-in upgrade
83/// from "read file + [`parse`]"; the only observable difference is that
84/// `(<)` directives now work.
85///
86/// # Errors
87///
88/// In addition to the variants from [`parse`], returns [`ParseError::Compose`]
89/// when the composer cannot read a referenced file, detects an include cycle,
90/// or rejects a malformed directive.
91pub fn parse_path(path: impl AsRef<Path>) -> Result<ir::Schema, ParseError> {
92    let doc = kdl_compose::compose(path)?;
93    parse_doc(&doc)
94}
95
96// =============================================================================
97// Lowering — raw structs → IR
98// =============================================================================
99
100fn lower_schema(raw: raw::RawSchema) -> Result<ir::Schema, ParseError> {
101    let mut types = Vec::with_capacity(raw.structs.len() + raw.enums.len());
102    for s in raw.structs {
103        types.push(lower_struct(s)?);
104    }
105    for e in raw.enums {
106        types.push(lower_enum(e));
107    }
108    let records = raw
109        .records
110        .into_iter()
111        .map(lower_record)
112        .collect::<Result<Vec<_>, _>>()?;
113    let relations = raw
114        .relations
115        .into_iter()
116        .map(lower_relation)
117        .collect::<Result<Vec<_>, _>>()?;
118    let protocol = raw.protocol.map(lower_protocol).transpose()?;
119    let schema = ir::Schema {
120        types,
121        records,
122        relations,
123        protocol,
124    };
125    validate_type_refs(&schema)?;
126    Ok(schema)
127}
128
129/// The set of type names a schema defines, split by reference kind.
130///
131/// A [`ir::Ty::Named`] (embedded value) must resolve into [`Self::values`];
132/// a [`ir::Ty::Link`] (stored reference) must resolve into [`Self::records`].
133struct DefinedNames<'a> {
134    /// `struct` / `enum` names — valid [`ir::Ty::Named`] targets.
135    values: HashSet<&'a str>,
136    /// `record` names — valid [`ir::Ty::Link`] targets.
137    records: HashSet<&'a str>,
138}
139
140/// Check that every type reference resolves: a [`ir::Ty::Named`] to a defined
141/// `struct` / `enum`, and a [`ir::Ty::Link`] to a defined `record`. Without
142/// this, an unknown type name silently flows through to an emitter and
143/// produces source that fails to compile.
144fn validate_type_refs(schema: &ir::Schema) -> Result<(), ParseError> {
145    let values: HashSet<&str> = schema
146        .types
147        .iter()
148        .map(|t| match t {
149            ir::TypeDef::Struct { name, .. } | ir::TypeDef::Enum { name, .. } => name.as_str(),
150        })
151        .collect();
152    let records: HashSet<&str> = schema.records.iter().map(|r| r.name.as_str()).collect();
153    let defined = DefinedNames { values, records };
154
155    for ty in &schema.types {
156        if let ir::TypeDef::Struct { fields, .. } = ty {
157            check_fields(fields, &defined)?;
158        }
159    }
160    for record in &schema.records {
161        check_fields(&record.fields, &defined)?;
162    }
163    for relation in &schema.relations {
164        check_fields(&relation.fields, &defined)?;
165        // A relation's endpoints must name defined records.
166        for (role, endpoint) in [("from", &relation.from), ("to", &relation.to)] {
167            if !defined.records.contains(endpoint.as_str()) {
168                return Err(ParseError::Validation(format!(
169                    "relation {:?} {role}={endpoint:?} references unknown record; \
170                     define it as a `record`",
171                    relation.name
172                )));
173            }
174        }
175    }
176    if let Some(protocol) = &schema.protocol {
177        for channel in &protocol.channels {
178            for request in &channel.requests {
179                check_fields(&request.fields, &defined)?;
180                if let Some(returns) = &request.returns {
181                    check_fields(&returns.fields, &defined)?;
182                }
183            }
184            for event in &channel.events {
185                check_fields(&event.fields, &defined)?;
186            }
187        }
188    }
189    Ok(())
190}
191
192fn check_fields(fields: &[ir::Field], defined: &DefinedNames) -> Result<(), ParseError> {
193    for field in fields {
194        check_ty(&field.ty, &field.name, defined)?;
195    }
196    Ok(())
197}
198
199fn check_ty(ty: &ir::Ty, field: &str, defined: &DefinedNames) -> Result<(), ParseError> {
200    match ty {
201        ir::Ty::Primitive(_) | ir::Ty::Literal(_) => Ok(()),
202        ir::Ty::Array(inner) => check_ty(inner, field, defined),
203        ir::Ty::Union(members) => members.iter().try_for_each(|m| check_ty(m, field, defined)),
204        ir::Ty::Named(name) if defined.values.contains(name.as_str()) => Ok(()),
205        ir::Ty::Named(name) => Err(ParseError::Validation(format!(
206            "field {field:?} references unknown type {name:?}; \
207             define it as a `struct` or `enum`, or use `link<{name}>` for a `record`"
208        ))),
209        ir::Ty::Link(name) if defined.records.contains(name.as_str()) => Ok(()),
210        ir::Ty::Link(name) => Err(ParseError::Validation(format!(
211            "field {field:?} links to unknown record {name:?}; \
212             define it as a `record`"
213        ))),
214    }
215}
216
217fn lower_struct(raw: raw::RawStruct) -> Result<ir::TypeDef, ParseError> {
218    Ok(ir::TypeDef::Struct {
219        name: raw.name,
220        description: raw.description,
221        fields: lower_fields(raw.fields)?,
222    })
223}
224
225fn lower_enum(raw: raw::RawEnum) -> ir::TypeDef {
226    ir::TypeDef::Enum {
227        name: raw.name,
228        description: raw.description,
229        variants: raw.variants.into_iter().map(|v| v.name).collect(),
230    }
231}
232
233fn lower_record(raw: raw::RawRecord) -> Result<ir::Record, ParseError> {
234    let id_strategy = lower_id_strategy(raw.id.and_then(|i| i.strategy).as_deref())?;
235    Ok(ir::Record {
236        name: raw.name,
237        description: raw.description,
238        id_strategy,
239        fields: lower_fields(raw.fields)?,
240    })
241}
242
243fn lower_relation(raw: raw::RawRelation) -> Result<ir::Relation, ParseError> {
244    Ok(ir::Relation {
245        name: raw.name,
246        description: raw.description,
247        from: raw.from,
248        to: raw.to,
249        unique: raw.unique,
250        fields: lower_fields(raw.fields)?,
251    })
252}
253
254fn lower_id_strategy(s: Option<&str>) -> Result<ir::IdStrategy, ParseError> {
255    match s {
256        None | Some("uuidv7") => Ok(ir::IdStrategy::Uuidv7),
257        Some("ulid") => Ok(ir::IdStrategy::Ulid),
258        Some("manual") => Ok(ir::IdStrategy::Manual),
259        Some(other) => Err(ParseError::Validation(format!(
260            "unknown id `strategy` value {other:?} (expected uuidv7/ulid/manual)"
261        ))),
262    }
263}
264
265fn lower_protocol(raw: raw::RawProtocol) -> Result<ir::Protocol, ParseError> {
266    let channels = raw
267        .channels
268        .into_iter()
269        .map(lower_channel)
270        .collect::<Result<Vec<_>, _>>()?;
271    Ok(ir::Protocol {
272        name: raw.name,
273        version: raw.version,
274        namespace: raw.namespace,
275        description: raw.description,
276        channels,
277    })
278}
279
280fn lower_channel(raw: raw::RawChannel) -> Result<ir::Channel, ParseError> {
281    let from = lower_channel_from(&raw.from)?;
282    let lifetime = lower_channel_lifetime(&raw.lifetime)?;
283    let backend = lower_channel_backend(raw.backend.as_deref())?;
284    let requests = raw
285        .requests
286        .into_iter()
287        .map(lower_request)
288        .collect::<Result<Vec<_>, _>>()?;
289    let events = raw
290        .events
291        .into_iter()
292        .map(lower_event)
293        .collect::<Result<Vec<_>, _>>()?;
294
295    // Semantic validation — mirrors club-unison's `Channel::validate`.
296    if backend == ir::ChannelBackend::Datagram {
297        match raw.channel_id {
298            None => {
299                return Err(ParseError::Validation(format!(
300                    "channel {:?} has backend=\"datagram\" but no channel_id; \
301                     channel_id=N (1..) is required",
302                    raw.name
303                )));
304            }
305            Some(0) => {
306                return Err(ParseError::Validation(format!(
307                    "channel {:?} has channel_id=0 which is reserved; use 1..",
308                    raw.name
309                )));
310            }
311            Some(_) => {}
312        }
313        if !requests.is_empty() {
314            return Err(ParseError::Validation(format!(
315                "channel {:?} has backend=\"datagram\" with request blocks; \
316                 datagram channels support event only (no Request/Response)",
317                raw.name
318            )));
319        }
320    }
321
322    Ok(ir::Channel {
323        name: raw.name,
324        from,
325        lifetime,
326        backend,
327        channel_id: raw.channel_id,
328        envelope: raw.envelope,
329        requests,
330        events,
331    })
332}
333
334fn lower_request(raw: raw::RawRequest) -> Result<ir::Request, ParseError> {
335    Ok(ir::Request {
336        name: raw.name,
337        fields: lower_fields(raw.fields)?,
338        returns: raw.returns.map(lower_message).transpose()?,
339    })
340}
341
342fn lower_event(raw: raw::RawEvent) -> Result<ir::Event, ParseError> {
343    Ok(ir::Event {
344        name: raw.name,
345        fields: lower_fields(raw.fields)?,
346    })
347}
348
349fn lower_message(raw: raw::RawMessage) -> Result<ir::Message, ParseError> {
350    Ok(ir::Message {
351        name: raw.name,
352        fields: lower_fields(raw.fields)?,
353    })
354}
355
356fn lower_fields(raw: Vec<raw::RawField>) -> Result<Vec<ir::Field>, ParseError> {
357    raw.into_iter().map(lower_field).collect()
358}
359
360fn lower_field(raw: raw::RawField) -> Result<ir::Field, ParseError> {
361    Ok(ir::Field {
362        ty: parse_ty(&raw.type_str)?,
363        name: raw.name,
364        required: !raw.optional,
365        flexible: raw.flexible,
366        default: raw.default,
367        description: raw.description,
368        constraints: ir::Constraints {
369            min: raw.min,
370            max: raw.max,
371            min_length: raw.min_length,
372            max_length: raw.max_length,
373            pattern: raw.pattern,
374        },
375    })
376}
377
378// =============================================================================
379// Scalar lowering helpers
380// =============================================================================
381
382fn lower_channel_from(s: &str) -> Result<ir::ChannelFrom, ParseError> {
383    match s {
384        "client" => Ok(ir::ChannelFrom::Client),
385        "server" => Ok(ir::ChannelFrom::Server),
386        "either" => Ok(ir::ChannelFrom::Either),
387        other => Err(ParseError::Validation(format!(
388            "unknown channel `from` value {other:?} (expected client/server/either)"
389        ))),
390    }
391}
392
393fn lower_channel_lifetime(s: &str) -> Result<ir::ChannelLifetime, ParseError> {
394    match s {
395        "transient" => Ok(ir::ChannelLifetime::Transient),
396        "persistent" => Ok(ir::ChannelLifetime::Persistent),
397        other => Err(ParseError::Validation(format!(
398            "unknown channel `lifetime` value {other:?} (expected transient/persistent)"
399        ))),
400    }
401}
402
403fn lower_channel_backend(s: Option<&str>) -> Result<ir::ChannelBackend, ParseError> {
404    match s {
405        None | Some("stream") => Ok(ir::ChannelBackend::Stream),
406        Some("datagram") => Ok(ir::ChannelBackend::Datagram),
407        Some(other) => Err(ParseError::Validation(format!(
408            "unknown channel `backend` value {other:?} (expected stream/datagram)"
409        ))),
410    }
411}
412
413/// Parse a field-type string into a [`Ty`](ir::Ty).
414///
415/// Grammar (lowest precedence first):
416///
417/// - `A | B | ...` — a [`Ty::Union`](ir::Ty::Union). `|` is split only at
418///   the top level (not inside `array<...>` / `link<...>`).
419/// - `array<T>` — a [`Ty::Array`](ir::Ty::Array).
420/// - `link<Name>` — a [`Ty::Link`](ir::Ty::Link), a stored record reference.
421/// - `'literal'` — a [`Ty::Literal`](ir::Ty::Literal) (single-quoted).
422/// - the primitive set — `string` / `int` / `float` / `bool` / `datetime` /
423///   `json`. `object` aliases `json`, `number` aliases `float`, `timestamp`
424///   aliases `datetime`.
425/// - any other identifier — a [`Ty::Named`](ir::Ty::Named) reference to a
426///   `struct` / `enum`.
427fn parse_ty(s: &str) -> Result<ir::Ty, ParseError> {
428    let s = s.trim();
429    if s.is_empty() {
430        return Err(ParseError::Validation("empty field type".to_string()));
431    }
432
433    // Union has the lowest precedence — split on top-level `|`.
434    let members = split_top_level_union(s);
435    if members.len() > 1 {
436        let parsed = members
437            .iter()
438            .map(|m| parse_ty(m))
439            .collect::<Result<Vec<_>, _>>()?;
440        return Ok(ir::Ty::Union(parsed));
441    }
442
443    parse_atom(s)
444}
445
446/// Parse a single (non-union) type atom.
447fn parse_atom(s: &str) -> Result<ir::Ty, ParseError> {
448    let s = s.trim();
449    if let Some(inner) = s.strip_prefix("array<").and_then(|r| r.strip_suffix('>')) {
450        return Ok(ir::Ty::Array(Box::new(parse_ty(inner)?)));
451    }
452    if let Some(inner) = s.strip_prefix("link<").and_then(|r| r.strip_suffix('>')) {
453        let name = inner.trim();
454        if name.is_empty() {
455            return Err(ParseError::Validation(
456                "empty record name in `link<>`".to_string(),
457            ));
458        }
459        return Ok(ir::Ty::Link(name.to_string()));
460    }
461    // String literal: `'value'`.
462    if let Some(inner) = s.strip_prefix('\'').and_then(|r| r.strip_suffix('\'')) {
463        return Ok(ir::Ty::Literal(inner.to_string()));
464    }
465    let prim = match s {
466        "string" => Some(ir::Prim::String),
467        "int" => Some(ir::Prim::Int),
468        "float" | "number" => Some(ir::Prim::Float),
469        "bool" => Some(ir::Prim::Bool),
470        "datetime" | "timestamp" => Some(ir::Prim::Datetime),
471        "json" | "object" => Some(ir::Prim::Json),
472        _ => None,
473    };
474    match prim {
475        Some(p) => Ok(ir::Ty::Primitive(p)),
476        None if s.is_empty() => Err(ParseError::Validation("empty field type".to_string())),
477        None => Ok(ir::Ty::Named(s.to_string())),
478    }
479}
480
481/// Split a type string on top-level `|`, ignoring `|` nested inside `<...>`
482/// brackets or `'...'` string literals. Returns the trimmed segments.
483fn split_top_level_union(s: &str) -> Vec<String> {
484    let mut parts: Vec<String> = Vec::new();
485    let mut cur = String::new();
486    let mut depth: usize = 0;
487    let mut in_literal = false;
488    for c in s.chars() {
489        match c {
490            '\'' => {
491                in_literal = !in_literal;
492                cur.push(c);
493            }
494            '<' if !in_literal => {
495                depth += 1;
496                cur.push(c);
497            }
498            '>' if !in_literal => {
499                depth = depth.saturating_sub(1);
500                cur.push(c);
501            }
502            '|' if depth == 0 && !in_literal => {
503                parts.push(cur.trim().to_string());
504                cur.clear();
505            }
506            _ => cur.push(c),
507        }
508    }
509    parts.push(cur.trim().to_string());
510    parts
511}
512
513// =============================================================================
514// Tests
515// =============================================================================
516
517#[cfg(test)]
518mod tests {
519    use super::*;
520
521    #[test]
522    fn parses_a_protocol_with_request_and_returns() {
523        let src = r#"
524            protocol "ping-pong" version="2.0.0" {
525                namespace "test.pp"
526                channel "pp" from="client" lifetime="persistent" {
527                    request "Ping" {
528                        field "message" type="string"
529                        returns "Pong" {
530                            field "reply" type="string"
531                        }
532                    }
533                }
534            }
535        "#;
536        let schema = parse(src).expect("should parse");
537        let protocol = schema.protocol.expect("has protocol");
538        assert_eq!(protocol.name, "ping-pong");
539        assert_eq!(protocol.version, "2.0.0");
540        assert_eq!(protocol.namespace.as_deref(), Some("test.pp"));
541        assert_eq!(protocol.channels.len(), 1);
542
543        let channel = &protocol.channels[0];
544        assert_eq!(channel.name, "pp");
545        assert_eq!(channel.from, ir::ChannelFrom::Client);
546        assert_eq!(channel.lifetime, ir::ChannelLifetime::Persistent);
547        assert_eq!(channel.backend, ir::ChannelBackend::Stream);
548        assert_eq!(channel.requests.len(), 1);
549
550        let request = &channel.requests[0];
551        assert_eq!(request.name, "Ping");
552        assert_eq!(
553            request.fields,
554            vec![ir::Field {
555                name: "message".to_string(),
556                ty: ir::Ty::Primitive(ir::Prim::String),
557                required: true,
558                flexible: false,
559                default: None,
560                description: None,
561                constraints: ir::Constraints::default(),
562            }]
563        );
564        let returns = request.returns.as_ref().expect("has returns");
565        assert_eq!(returns.name, "Pong");
566        assert_eq!(returns.fields[0].name, "reply");
567    }
568
569    #[test]
570    fn parses_events_and_optional_fields() {
571        let src = r#"
572            protocol "p" version="1.0.0" {
573                channel "c" from="server" lifetime="persistent" {
574                    event "Tick" {
575                        field "seq" type="int"
576                        field "note" type="string" optional=#true
577                    }
578                }
579            }
580        "#;
581        let schema = parse(src).expect("should parse");
582        let channel = &schema.protocol.unwrap().channels[0];
583        let event = &channel.events[0];
584        assert_eq!(event.name, "Tick");
585        assert!(
586            event.fields[0].required,
587            "unmarked field defaults to required"
588        );
589        assert!(
590            !event.fields[1].required,
591            "explicit optional=#true → not required"
592        );
593    }
594
595    #[test]
596    fn datagram_channel_requires_channel_id() {
597        let src = r#"
598            protocol "p" version="1.0.0" {
599                channel "metric" from="server" lifetime="persistent" backend="datagram" {
600                    event "M" { field "v" type="int" }
601                }
602            }
603        "#;
604        let err = parse(src).expect_err("datagram without channel_id is invalid");
605        assert!(matches!(err, ParseError::Validation(_)));
606    }
607
608    #[test]
609    fn datagram_channel_rejects_requests() {
610        let src = r#"
611            protocol "p" version="1.0.0" {
612                channel "c" from="client" lifetime="persistent" backend="datagram" channel_id=1 {
613                    request "R" { field "x" type="int" }
614                }
615            }
616        "#;
617        let err = parse(src).expect_err("datagram with request is invalid");
618        assert!(matches!(err, ParseError::Validation(_)));
619    }
620
621    #[test]
622    fn parses_datagram_channel_with_id() {
623        let src = r#"
624            protocol "p" version="1.0.0" {
625                channel "metric" from="server" lifetime="persistent" backend="datagram" channel_id=7 {
626                    event "M" { field "v" type="int" }
627                }
628            }
629        "#;
630        let channel = &parse(src).unwrap().protocol.unwrap().channels[0];
631        assert_eq!(channel.backend, ir::ChannelBackend::Datagram);
632        assert_eq!(channel.channel_id, Some(7));
633    }
634
635    #[test]
636    fn parses_data_dialect_struct_and_enum() {
637        let src = r#"
638            struct "User" {
639                field "id" type="string"
640                field "tags" type="array<string>"
641                field "role" type="Role"
642            }
643            enum "Role" {
644                variant "admin"
645                variant "member"
646            }
647        "#;
648        let schema = parse(src).expect("should parse");
649        assert!(schema.protocol.is_none());
650        assert_eq!(schema.types.len(), 2);
651
652        match &schema.types[0] {
653            ir::TypeDef::Struct { name, fields, .. } => {
654                assert_eq!(name, "User");
655                assert_eq!(
656                    fields[1].ty,
657                    ir::Ty::Array(Box::new(ir::Ty::Primitive(ir::Prim::String)))
658                );
659                assert_eq!(fields[2].ty, ir::Ty::Named("Role".to_string()));
660            }
661            other => panic!("expected struct, got {other:?}"),
662        }
663        match &schema.types[1] {
664            ir::TypeDef::Enum { name, variants, .. } => {
665                assert_eq!(name, "Role");
666                assert_eq!(variants, &["admin", "member"]);
667            }
668            other => panic!("expected enum, got {other:?}"),
669        }
670    }
671
672    #[test]
673    fn rejects_unknown_channel_from() {
674        let src = r#"
675            protocol "p" version="1.0.0" {
676                channel "c" from="nobody" lifetime="persistent" {
677                    event "E" { field "x" type="int" }
678                }
679            }
680        "#;
681        let err = parse(src).expect_err("unknown from value is invalid");
682        assert!(matches!(err, ParseError::Validation(_)));
683    }
684
685    #[test]
686    fn primitive_type_aliases() {
687        assert_eq!(
688            parse_ty("object").unwrap(),
689            ir::Ty::Primitive(ir::Prim::Json)
690        );
691        assert_eq!(
692            parse_ty("number").unwrap(),
693            ir::Ty::Primitive(ir::Prim::Float)
694        );
695        assert_eq!(
696            parse_ty("timestamp").unwrap(),
697            ir::Ty::Primitive(ir::Prim::Datetime)
698        );
699    }
700
701    #[test]
702    fn rejects_unknown_type_reference() {
703        let src = r#"
704            struct "User" {
705                field "role" type="Role"
706            }
707        "#;
708        // `Role` is never defined as a struct/enum.
709        let err = parse(src).expect_err("unknown type reference is invalid");
710        assert!(matches!(err, ParseError::Validation(_)));
711    }
712
713    #[test]
714    fn accepts_array_of_defined_type() {
715        let src = r#"
716            struct "Team" {
717                field "members" type="array<User>"
718            }
719            struct "User" {
720                field "id" type="string"
721            }
722        "#;
723        // `array<User>` resolves because `User` is defined; order-independent.
724        parse(src).expect("array<User> with User defined should parse");
725    }
726
727    // -------------------------------------------------------------------------
728    // Tier 1 — record / relation / link / union
729    // -------------------------------------------------------------------------
730
731    #[test]
732    fn parses_record_with_id_strategy_and_fields() {
733        let src = r#"
734            record "Atlas" {
735                id strategy="uuidv7"
736                field "name"   type="string"
737                field "parent" type="link<Atlas>"
738            }
739        "#;
740        let schema = parse(src).expect("record should parse");
741        assert_eq!(schema.records.len(), 1);
742        let atlas = &schema.records[0];
743        assert_eq!(atlas.name, "Atlas");
744        assert_eq!(atlas.id_strategy, ir::IdStrategy::Uuidv7);
745        assert_eq!(atlas.fields[0].name, "name");
746        // self-link resolves because `Atlas` is itself a defined record.
747        assert_eq!(atlas.fields[1].ty, ir::Ty::Link("Atlas".to_string()));
748    }
749
750    #[test]
751    fn record_id_strategy_defaults_to_uuidv7_when_absent() {
752        let src = r#"
753            record "Note" {
754                field "body" type="string"
755            }
756        "#;
757        let schema = parse(src).expect("record without `id` node should parse");
758        assert_eq!(schema.records[0].id_strategy, ir::IdStrategy::Uuidv7);
759    }
760
761    #[test]
762    fn parses_all_id_strategies() {
763        for (kw, expected) in [
764            ("ulid", ir::IdStrategy::Ulid),
765            ("manual", ir::IdStrategy::Manual),
766            ("uuidv7", ir::IdStrategy::Uuidv7),
767        ] {
768            let src = format!(
769                r#"record "R" {{ id strategy="{kw}"
770                       field "x" type="string" }}"#
771            );
772            let schema = parse(&src).expect("record parses");
773            assert_eq!(schema.records[0].id_strategy, expected);
774        }
775    }
776
777    #[test]
778    fn rejects_unknown_id_strategy() {
779        let src = r#"record "R" { id strategy="snowflake"
780                       field "x" type="string" }"#;
781        let err = parse(src).expect_err("unknown id strategy is invalid");
782        assert!(matches!(err, ParseError::Validation(_)));
783    }
784
785    #[test]
786    fn parses_relation_with_endpoints_and_edge_fields() {
787        let src = r#"
788            record "Memory" {
789                field "body" type="string"
790            }
791            relation "derivedFrom" from="Memory" to="Memory" unique=#true {
792                field "confidence" type="float"
793                field "reason"     type="string"
794            }
795        "#;
796        let schema = parse(src).expect("relation should parse");
797        assert_eq!(schema.relations.len(), 1);
798        let rel = &schema.relations[0];
799        assert_eq!(rel.name, "derivedFrom");
800        assert_eq!(rel.from, "Memory");
801        assert_eq!(rel.to, "Memory");
802        assert!(rel.unique);
803        assert_eq!(rel.fields.len(), 2);
804        assert_eq!(rel.fields[0].name, "confidence");
805    }
806
807    #[test]
808    fn relation_unique_defaults_to_false() {
809        let src = r#"
810            record "A" { field "x" type="string" }
811            relation "rel" from="A" to="A"
812        "#;
813        let schema = parse(src).expect("relation parses");
814        assert!(!schema.relations[0].unique);
815    }
816
817    #[test]
818    fn rejects_relation_with_unknown_endpoint() {
819        let src = r#"
820            record "A" { field "x" type="string" }
821            relation "rel" from="A" to="Ghost"
822        "#;
823        let err = parse(src).expect_err("unknown relation endpoint is invalid");
824        assert!(matches!(err, ParseError::Validation(_)));
825    }
826
827    #[test]
828    fn parse_ty_link_and_literal_and_union() {
829        assert_eq!(
830            parse_ty("link<Atlas>").unwrap(),
831            ir::Ty::Link("Atlas".to_string())
832        );
833        assert_eq!(
834            parse_ty("'public'").unwrap(),
835            ir::Ty::Literal("public".to_string())
836        );
837        assert_eq!(
838            parse_ty("'public' | 'private'").unwrap(),
839            ir::Ty::Union(vec![
840                ir::Ty::Literal("public".to_string()),
841                ir::Ty::Literal("private".to_string()),
842            ])
843        );
844        assert_eq!(
845            parse_ty("string | int | bool").unwrap(),
846            ir::Ty::Union(vec![
847                ir::Ty::Primitive(ir::Prim::String),
848                ir::Ty::Primitive(ir::Prim::Int),
849                ir::Ty::Primitive(ir::Prim::Bool),
850            ])
851        );
852    }
853
854    #[test]
855    fn union_does_not_split_inside_brackets() {
856        // `|` inside `array<...>` must not be treated as a union separator.
857        // (there is no nested union here, but the splitter must stay depth-aware)
858        assert_eq!(
859            parse_ty("array<string>").unwrap(),
860            ir::Ty::Array(Box::new(ir::Ty::Primitive(ir::Prim::String)))
861        );
862    }
863
864    #[test]
865    fn link_to_unknown_record_is_rejected() {
866        let src = r#"
867            record "Atlas" {
868                field "parent" type="link<Ghost>"
869            }
870        "#;
871        let err = parse(src).expect_err("link to undefined record is invalid");
872        assert!(matches!(err, ParseError::Validation(_)));
873    }
874
875    #[test]
876    fn flexible_and_default_properties_are_lowered() {
877        let src = r#"
878            record "Atlas" {
879                field "metadata"   type="object" flexible=#true
880                field "visibility" type="'public' | 'private'" default="private"
881            }
882        "#;
883        let schema = parse(src).expect("record should parse");
884        let fields = &schema.records[0].fields;
885        assert!(fields[0].flexible, "flexible=#true is lowered");
886        assert!(!fields[1].flexible, "absent flexible defaults false");
887        assert_eq!(fields[1].default.as_deref(), Some("private"));
888    }
889
890    #[test]
891    fn bare_name_is_embedded_link_is_stored() {
892        // A bare `Name` resolves to a struct/enum; `link<Name>` to a record.
893        // The same identifier in both forms must keep its distinct meaning.
894        let src = r#"
895            struct "GeoPoint" {
896                field "lat" type="float"
897            }
898            record "Place" {
899                field "at"     type="GeoPoint"
900                field "parent" type="link<Place>"
901            }
902        "#;
903        let schema = parse(src).expect("schema parses");
904        let fields = &schema.records[0].fields;
905        assert_eq!(fields[0].ty, ir::Ty::Named("GeoPoint".to_string()));
906        assert_eq!(fields[1].ty, ir::Ty::Link("Place".to_string()));
907    }
908
909    // -------------------------------------------------------------------------
910    // Tier 2 — description / constraints
911    // -------------------------------------------------------------------------
912
913    #[test]
914    fn record_and_field_descriptions_are_lowered() {
915        let src = r#"
916            record "Memory" description="User memory with content" {
917                field "content" type="string" description="Memory content text"
918            }
919        "#;
920        let schema = parse(src).expect("record with descriptions parses");
921        let memory = &schema.records[0];
922        assert_eq!(
923            memory.description.as_deref(),
924            Some("User memory with content")
925        );
926        assert_eq!(
927            memory.fields[0].description.as_deref(),
928            Some("Memory content text")
929        );
930    }
931
932    #[test]
933    fn struct_enum_relation_descriptions_are_lowered() {
934        let src = r#"
935            struct "Point" description="A 2D point" {
936                field "x" type="float"
937            }
938            enum "Color" description="An RGB primary" {
939                variant "red"
940            }
941            record "Node" { field "v" type="int" }
942            relation "edge" from="Node" to="Node" description="A directed edge"
943        "#;
944        let schema = parse(src).expect("schema parses");
945        match &schema.types[0] {
946            ir::TypeDef::Struct { description, .. } => {
947                assert_eq!(description.as_deref(), Some("A 2D point"));
948            }
949            other => panic!("expected struct, got {other:?}"),
950        }
951        match &schema.types[1] {
952            ir::TypeDef::Enum { description, .. } => {
953                assert_eq!(description.as_deref(), Some("An RGB primary"));
954            }
955            other => panic!("expected enum, got {other:?}"),
956        }
957        assert_eq!(
958            schema.relations[0].description.as_deref(),
959            Some("A directed edge")
960        );
961    }
962
963    #[test]
964    fn field_constraints_are_lowered() {
965        let src = r#"
966            struct "Profile" {
967                field "confidence" type="float" min=0 max=1
968                field "name"       type="string" min_length=1 max_length=32 pattern="^[a-z]+$"
969            }
970        "#;
971        let schema = parse(src).expect("schema parses");
972        let fields = match &schema.types[0] {
973            ir::TypeDef::Struct { fields, .. } => fields,
974            other => panic!("expected struct, got {other:?}"),
975        };
976        assert_eq!(fields[0].constraints.min, Some(0));
977        assert_eq!(fields[0].constraints.max, Some(1));
978        assert_eq!(fields[1].constraints.min_length, Some(1));
979        assert_eq!(fields[1].constraints.max_length, Some(32));
980        assert_eq!(fields[1].constraints.pattern.as_deref(), Some("^[a-z]+$"));
981    }
982
983    #[test]
984    fn absent_constraints_and_description_default_to_none() {
985        let src = r#"
986            struct "Bare" {
987                field "x" type="int"
988            }
989        "#;
990        let schema = parse(src).expect("schema parses");
991        let fields = match &schema.types[0] {
992            ir::TypeDef::Struct { fields, .. } => fields,
993            other => panic!("expected struct, got {other:?}"),
994        };
995        assert!(fields[0].description.is_none());
996        assert!(
997            fields[0].constraints.is_empty(),
998            "a field with no constraint properties has empty Constraints"
999        );
1000    }
1001}