Skip to main content

omnist/
osd.rs

1//! OSD (Omnist Schema Definition) -- the text language for the [`crate::schema`]
2//! model. Ported from `~/dev/omnist/omnist/osd.py`.
3//!
4//! Grammar (informal):
5//!
6//! ```text
7//! schema      := record* 'root' NAME
8//! record      := 'record' NAME '{' field (',' field)* ','? '}'
9//! field       := STRING cardinality? ':' type
10//! cardinality := '[' INT? (',' INT?)? ']'   -- [m,n] [m,] [,n] [n]; absent = [1,1]
11//! type        := SCALARNAME '?'? | NAME     -- one scalar, or one Ref
12//! ```
13//!
14//! Quoting rule: a `"quoted"` token is always a field label (data string);
15//! an unquoted identifier is always a schema name (scalar keyword or Ref).
16//! There is no value-domain composition (no `|`, enum, literal fields, or
17//! `union`).
18//!
19//! ## The `any` keyword
20//!
21//! Python's `osd.py` recognizes `"any"` as a reserved type keyword and
22//! parses it to `ANY` (`RESERVED_TYPE_NAMES = SCALAR_NAMES | {"any"}`). This
23//! module mirrors that: `any` in a type position parses to
24//! [`crate::schema::FieldType::Any`], and -- exactly like Python -- `any` is
25//! still a reserved name that cannot be used as a record name (matching the
26//! grammar). Since `Any` already includes `null`, a trailing `?` after `any`
27//! (`"a": any?`) is a [`SchemaError`] ("redundant"), not silently accepted.
28
29use indexmap::IndexMap;
30use regex::Regex;
31use std::sync::LazyLock;
32
33use crate::error::SchemaError;
34use crate::schema::{Field, FieldType, Record, Ref, Scalar, ScalarKind, Schema};
35
36// ---------------------------------------------------------------------------
37// Tokenizer
38// ---------------------------------------------------------------------------
39
40static TOKEN_RE: LazyLock<Regex> = LazyLock::new(|| {
41    Regex::new(
42        r#"(?x)
43          (?P<ws>\s+)
44        | (?P<comment>\#[^\n]*)
45        | (?P<string>"(?:\\.|[^"\\])*")
46        | (?P<number>-?\d+\.\d+|-?\d+)
47        | (?P<name>[A-Za-z_][A-Za-z0-9_]*)
48        | (?P<punct>[{}\[\]:,?])
49        "#,
50    )
51    .unwrap()
52});
53
54/// A token kind. `Ws`/`Comment` never make it into the token stream produced
55/// by [`tokenize`] -- they're skipped there, exactly like Python's
56/// `_tokenize`, which is why they still exist as variants (the regex names
57/// them) but are unreachable outside this module.
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59enum TokKind {
60    String,
61    Number,
62    Name,
63    Punct,
64    Eof,
65}
66
67#[derive(Debug, Clone, PartialEq, Eq)]
68struct Tok {
69    kind: TokKind,
70    text: String,
71    pos: usize,
72}
73
74/// Tokenize OSD source, mirroring Python's `_tokenize`: whitespace and
75/// `#`-comments are dropped; everything else becomes a [`Tok`], with a
76/// trailing `Eof` sentinel. Returns a [`SchemaError`] naming the offending
77/// character and byte offset on the first unrecognized character.
78fn tokenize(text: &str) -> Result<Vec<Tok>, SchemaError> {
79    let mut toks = Vec::new();
80    let mut i = 0usize;
81    while i < text.len() {
82        let Some(m) = TOKEN_RE.captures(&text[i..]) else {
83            let ch = text[i..].chars().next().unwrap();
84            return Err(SchemaError::new(
85                "$",
86                "parse.unexpected-token",
87                format!("unexpected character {ch:?} at {i}"),
88            ));
89        };
90        let whole = m.get(0).unwrap();
91        if whole.start() != 0 {
92            let ch = text[i..].chars().next().unwrap();
93            return Err(SchemaError::new(
94                "$",
95                "parse.unexpected-token",
96                format!("unexpected character {ch:?} at {i}"),
97            ));
98        }
99        let start = i;
100        i += whole.len();
101        if m.name("ws").is_some() || m.name("comment").is_some() {
102            continue;
103        }
104        let (kind, matched) = if let Some(g) = m.name("string") {
105            let s = g.as_str();
106            if let Some(c) = s.chars().find(|&c| (c as u32) < 0x20) {
107                return Err(SchemaError::new(
108                    "$",
109                    "parse.control-character",
110                    format!("control character U+{:04X} in string at {start}", c as u32),
111                ));
112            }
113            (TokKind::String, s)
114        } else if let Some(g) = m.name("number") {
115            (TokKind::Number, g.as_str())
116        } else if let Some(g) = m.name("name") {
117            (TokKind::Name, g.as_str())
118        } else {
119            (TokKind::Punct, m.name("punct").unwrap().as_str())
120        };
121        toks.push(Tok {
122            kind,
123            text: matched.to_string(),
124            pos: start,
125        });
126    }
127    toks.push(Tok {
128        kind: TokKind::Eof,
129        text: String::new(),
130        pos: text.len(),
131    });
132    Ok(toks)
133}
134
135/// Un-escape a quoted string token's raw text (including its surrounding
136/// `"`s) via `\X -> X`, mirroring Python's `_unquote` (`re.sub(r'\\(.)',
137/// r'\1', s[1:-1])`).
138fn unquote(s: &str) -> String {
139    let inner = &s[1..s.len() - 1];
140    let mut out = String::with_capacity(inner.len());
141    let mut chars = inner.chars();
142    while let Some(c) = chars.next() {
143        if c == '\\' {
144            if let Some(escaped) = chars.next() {
145                out.push(escaped);
146            }
147        } else {
148            out.push(c);
149        }
150    }
151    out
152}
153
154// ---------------------------------------------------------------------------
155// Parser
156// ---------------------------------------------------------------------------
157
158struct Parser {
159    toks: Vec<Tok>,
160    i: usize,
161}
162
163impl Parser {
164    fn new(toks: Vec<Tok>) -> Self {
165        Parser { toks, i: 0 }
166    }
167
168    fn peek(&self) -> &Tok {
169        &self.toks[self.i]
170    }
171
172    fn next_tok(&mut self) -> Tok {
173        let t = self.toks[self.i].clone();
174        self.i += 1;
175        t
176    }
177
178    fn expect_punct(&mut self, text: &str) -> Result<Tok, SchemaError> {
179        let t = self.next_tok();
180        if t.kind != TokKind::Punct || t.text != text {
181            return Err(SchemaError::new(
182                "$",
183                "parse.unexpected-token",
184                format!("expected {text:?} at {}, got {:?}", t.pos, t.text),
185            ));
186        }
187        Ok(t)
188    }
189
190    fn expect_name(&mut self) -> Result<Tok, SchemaError> {
191        let t = self.next_tok();
192        if t.kind != TokKind::Name {
193            return Err(SchemaError::new(
194                "$",
195                "parse.unexpected-token",
196                format!("expected a name at {}, got {:?}", t.pos, t.text),
197            ));
198        }
199        Ok(t)
200    }
201
202    fn parse_schema(&mut self) -> Result<Schema, SchemaError> {
203        let mut env: IndexMap<String, Record> = IndexMap::new();
204        let mut root: Option<String> = None;
205        while self.peek().kind != TokKind::Eof {
206            let t = self.peek().clone();
207            if t.kind == TokKind::Name && t.text == "record" {
208                let (name, rec, name_pos) = self.parse_record()?;
209                self.define(&mut env, name, rec, name_pos)?;
210            } else if t.kind == TokKind::Name && t.text == "root" {
211                self.next_tok();
212                let name = self.expect_name()?.text;
213                if root.is_some() {
214                    return Err(SchemaError::new(
215                        "$",
216                        "schema.duplicate-root",
217                        "a schema must declare exactly one root",
218                    ));
219                }
220                root = Some(name);
221            } else {
222                return Err(SchemaError::new(
223                    "$",
224                    "parse.unexpected-token",
225                    format!("expected 'record' or 'root' at {}, got {:?}", t.pos, t.text),
226                ));
227            }
228        }
229        let Some(root) = root else {
230            return Err(SchemaError::new(
231                "$",
232                "schema.no-root",
233                "a schema must declare a root",
234            ));
235        };
236        Schema::new(Ref::new(root), env)
237    }
238
239    fn define(
240        &self,
241        env: &mut IndexMap<String, Record>,
242        name: String,
243        rec: Record,
244        name_pos: usize,
245    ) -> Result<(), SchemaError> {
246        if name == "any" {
247            return Err(SchemaError::new(
248                "any",
249                "schema.reserved-name",
250                format!(
251                    "'any' is a reserved type name and cannot be used as a record name at {name_pos}"
252                ),
253            ));
254        }
255        if ScalarKind::ALL.iter().any(|k| k.as_str() == name) {
256            return Err(SchemaError::new(
257                &name,
258                "schema.reserved-name",
259                format!(
260                    "{name:?} is a reserved scalar name; a record cannot be defined with                      this name, or it could never be referenced (a bare name in a type                      position always means the builtin scalar)"
261                ),
262            ));
263        }
264        if env.contains_key(&name) {
265            return Err(SchemaError::new(
266                &name,
267                "schema.duplicate-record",
268                format!("duplicate definition {name:?}"),
269            ));
270        }
271        env.insert(name, rec);
272        Ok(())
273    }
274
275    /// Parses a `record NAME { ... }` block.
276    fn parse_record(&mut self) -> Result<(String, Record, usize), SchemaError> {
277        self.next_tok(); // guaranteed to be the `record` keyword
278        let name_tok = self.expect_name()?;
279        self.expect_punct("{")?;
280        let mut fields = Vec::new();
281        let mut seen = std::collections::BTreeSet::new();
282        while self.peek().text != "}" {
283            let f = self.parse_field(&name_tok.text)?;
284            if !seen.insert(f.label.clone()) {
285                return Err(SchemaError::new(
286                    &name_tok.text,
287                    "schema.duplicate-field",
288                    format!(
289                        "duplicate field label {:?} in record {:?}",
290                        f.label, name_tok.text
291                    ),
292                ));
293            }
294            fields.push(f);
295            if self.peek().text == "," {
296                self.next_tok();
297            } else {
298                break;
299            }
300        }
301        self.expect_punct("}")?;
302        let rec = Record::new(fields)?;
303        Ok((name_tok.text.clone(), rec, name_tok.pos))
304    }
305
306    fn parse_field(&mut self, rec_name: &str) -> Result<Field, SchemaError> {
307        let label_tok = self.next_tok();
308        if label_tok.kind != TokKind::String {
309            return Err(SchemaError::new(
310                rec_name,
311                "schema.unquoted-label",
312                format!(
313                    "expected a quoted field name at {}, got {:?}",
314                    label_tok.pos, label_tok.text
315                ),
316            ));
317        }
318        let label = unquote(&label_tok.text);
319        // Empty field label is a normative error (spec Sec5.4, issue #163,
320        // updated 2026-08-29): "" is a legal OSD *string* generally, but a
321        // label is an identifier, not a value -- an empty label names
322        // nothing a caller could ever reference. Path is the enclosing
323        // record (no usable label to point at), same convention
324        // `schema.unquoted-label` above already uses for the analogous
325        // "the label itself is the problem" case.
326        if label.is_empty() {
327            return Err(SchemaError::new(
328                rec_name,
329                "schema.empty-label",
330                format!("field label at {} is empty", label_tok.pos),
331            ));
332        }
333        // '[' / ']' in a field label is a normative error (spec Sec5.4,
334        // issue #166, updated 2026-08-29): Sec3.6.1's diagnostic-path
335        // convention appends "[i]" to a repeated label's second and later
336        // occurrences, so a repeatable field "a" and a separately
337        // declared field literally named "a[1]" can produce the identical
338        // diagnostic path "$.a[1]" for two genuinely different validation
339        // problems -- indistinguishable in a diagnostic. Narrowest fix
340        // (per the issue): reject the character vocabulary outright at
341        // schema-construction time, not just the specific "[i]" shape --
342        // a lone, unmatched ']' (e.g. "total]") is rejected too. Same
343        // path convention as the empty-label check above.
344        if label.contains('[') || label.contains(']') {
345            return Err(SchemaError::new(
346                rec_name,
347                "schema.bracket-in-label",
348                format!(
349                    "field label {label:?} at {} contains '[' or ']', which collides with the \
350                     [i] diagnostic-path convention for repeated labels",
351                    label_tok.pos
352                ),
353            ));
354        }
355        let (min, max) = if self.peek().text == "[" {
356            self.parse_cardinality(rec_name, &label)?
357        } else {
358            (1, Some(1))
359        };
360        self.expect_punct(":")?;
361        let ty = self.parse_type(rec_name, &label)?;
362        Field::new(label, ty, min, max)
363    }
364
365    fn parse_cardinality(
366        &mut self,
367        rec_name: &str,
368        label: &str,
369    ) -> Result<(usize, Option<usize>), SchemaError> {
370        self.expect_punct("[")?;
371        let path = format!("{rec_name}.{label}");
372        if self.peek().text == "]" {
373            return Err(SchemaError::new(
374                path,
375                "schema.empty-cardinality",
376                format!("empty cardinality at {}", self.peek().pos),
377            ));
378        }
379        let first = if self.peek().text == "," {
380            None
381        } else {
382            Some(self.parse_cardinality_int(rec_name, label)?)
383        };
384        let (lo, hi) = if self.peek().text == "," {
385            self.next_tok();
386            let second = if self.peek().text == "]" {
387                None
388            } else {
389                Some(self.parse_cardinality_int(rec_name, label)?)
390            };
391            (first.unwrap_or(0), second)
392        } else {
393            let bound = first.unwrap();
394            (bound, Some(bound))
395        };
396        self.expect_punct("]")?;
397        if let Some(hi) = hi
398            && hi < lo
399        {
400            return Err(SchemaError::new(
401                path,
402                "schema.invalid-cardinality",
403                format!("invalid cardinality range [{lo}, {hi}]"),
404            ));
405        }
406        // [0,0] is a normative error (spec Sec5.5, issue #158, updated
407        // 2026-08-24): a field that must occur zero times is
408        // indistinguishable, in every observable respect, from a field
409        // never declared at all -- records are closed by default, so an
410        // undeclared label present in a document is already an error
411        // (`validate.unexpected-field`). [0,0] was a second spelling for
412        // the same thing, redundant with just not declaring the field.
413        // Reuses the existing `schema.invalid-cardinality` code -- the
414        // spec explicitly calls for no new code here.
415        if lo == 0 && hi == Some(0) {
416            return Err(SchemaError::new(
417                path,
418                "schema.invalid-cardinality",
419                "cardinality [0, 0] is redundant with not declaring the field at all".to_string(),
420            ));
421        }
422        Ok((lo, hi))
423    }
424
425    fn parse_cardinality_int(&mut self, rec_name: &str, label: &str) -> Result<usize, SchemaError> {
426        let t = self.next_tok();
427        let path = format!("{rec_name}.{label}");
428        if t.text.contains('.') {
429            return Err(SchemaError::new(
430                path,
431                "schema.non-integer-cardinality",
432                format!(
433                    "cardinality must be a whole number, got {:?} at {}",
434                    t.text, t.pos
435                ),
436            ));
437        }
438        if t.text.starts_with('-') {
439            return Err(SchemaError::new(
440                path,
441                "schema.invalid-cardinality",
442                format!(
443                    "cardinality must be a non-negative whole number, got {:?} at {}",
444                    t.text, t.pos
445                ),
446            ));
447        }
448        t.text.parse::<usize>().map_err(|_| {
449            SchemaError::new(
450                path,
451                "schema.non-integer-cardinality",
452                format!(
453                    "cardinality must be a non-negative whole number, got {:?} at {}",
454                    t.text, t.pos
455                ),
456            )
457        })
458    }
459
460    fn parse_type(&mut self, rec_name: &str, label: &str) -> Result<FieldType, SchemaError> {
461        let t = self.next_tok();
462        let path = format!("{rec_name}.{label}");
463        if t.kind != TokKind::Name {
464            if t.kind == TokKind::String {
465                return Err(SchemaError::new(
466                    rec_name,
467                    "schema.quoted-type",
468                    format!(
469                        "expected a scalar name or a reference at {}, got {:?} (enums and                          literal-valued fields are not supported -- a field's type is                          always one scalar or a reference to a named record)",
470                        t.pos, t.text
471                    ),
472                ));
473            }
474            return Err(SchemaError::new(
475                rec_name,
476                "parse.unexpected-token",
477                format!(
478                    "expected a scalar name or a reference at {}, got {:?} (enums and                      literal-valued fields are not supported -- a field's type is                      always one scalar or a reference to a named record)",
479                    t.pos, t.text
480                ),
481            ));
482        }
483        if t.text == "any" {
484            if self.peek().text == "?" {
485                let q = self.next_tok();
486                return Err(SchemaError::new(
487                    path,
488                    "schema.nullable-any",
489                    format!(
490                        "'any' already includes null; 'any?' is redundant at {}",
491                        q.pos
492                    ),
493                ));
494            }
495            return Ok(FieldType::Any);
496        }
497        let mut nullable = false;
498        if self.peek().text == "?" {
499            self.next_tok();
500            nullable = true;
501        }
502        if ScalarKind::ALL.iter().any(|k| k.as_str() == t.text) {
503            return Ok(FieldType::Scalar(Scalar::named(&t.text, nullable)?));
504        }
505        if nullable {
506            return Err(SchemaError::new(
507                path,
508                "schema.nullable-ref",
509                format!(
510                    "'?' cannot apply to the reference {:?}; use cardinality [0,1] for                      an optional field",
511                    t.text
512                ),
513            ));
514        }
515        Ok(FieldType::Ref(Ref::new(t.text)))
516    }
517}
518
519/// Parse OSD text into a [`Schema`].
520pub fn parse_schema(text: &str) -> Result<Schema, SchemaError> {
521    let toks = tokenize(text)?;
522    Parser::new(toks).parse_schema()
523}
524
525// ---------------------------------------------------------------------------
526// Serialize a Schema back to OSD text
527// ---------------------------------------------------------------------------
528
529/// Serialize a [`Schema`] back to OSD text. Ported from Python's
530/// `osd.to_osd`/`_record`/`_field`/`_card`/`_type`.
531///
532/// `indent: None` renders a single-line, machine-oriented form (record
533/// definitions and the trailing `root` statement joined by spaces, fields
534/// joined by `, `, no trailing comma) instead of the default
535/// pretty-printed, indented form -- mirroring `write_oml`/`write_json`'s
536/// own `indent: None` convention. A `Some(n)` sets the pretty-mode indent
537/// width in spaces. Both forms round-trip through [`parse_schema`].
538pub fn to_osd(schema: &Schema, indent: Option<usize>) -> String {
539    let mut parts: Vec<String> = schema
540        .env()
541        .iter()
542        .map(|(name, rec)| osd_record(name, rec, indent))
543        .collect();
544    parts.push(format!("root {}", schema.root().name));
545    if indent.is_none() {
546        return format!("{}\n", parts.join(" "));
547    }
548    format!("{}\n", parts.join("\n"))
549}
550
551fn osd_record(name: &str, rec: &Record, indent: Option<usize>) -> String {
552    if indent.is_none() {
553        let fields: Vec<String> = rec.fields().iter().map(osd_field).collect();
554        return format!("record {name} {{ {} }}", fields.join(", "));
555    }
556    let pad = " ".repeat(indent.unwrap_or(4));
557    let mut out = vec![format!("record {name} {{")];
558    for f in rec.fields() {
559        out.push(format!("{pad}{},", osd_field(f)));
560    }
561    out.push("}".to_string());
562    out.join("\n")
563}
564
565fn quote_label(s: &str) -> String {
566    let mut out = String::with_capacity(s.len() + 2);
567    out.push('"');
568    for c in s.chars() {
569        if c == '\\' || c == '"' {
570            out.push('\\');
571        }
572        out.push(c);
573    }
574    out.push('"');
575    out
576}
577
578fn osd_field(f: &Field) -> String {
579    let card = if (f.min, f.max) == (1, Some(1)) {
580        String::new()
581    } else {
582        format!(" {}", osd_cardinality(f.min, f.max))
583    };
584    format!("{}{card}: {}", quote_label(&f.label), osd_type(&f.ty))
585}
586
587fn osd_cardinality(lo: usize, hi: Option<usize>) -> String {
588    match hi {
589        Some(hi) if hi == lo => format!("[{lo}]"),
590        Some(hi) => format!("[{lo},{hi}]"),
591        None => format!("[{lo},]"),
592    }
593}
594
595fn osd_type(t: &crate::schema::FieldType) -> String {
596    match t {
597        crate::schema::FieldType::Ref(r) => r.name.clone(),
598        crate::schema::FieldType::Scalar(s) => {
599            format!(
600                "{}{}",
601                s.kind().as_str(),
602                if s.is_nullable() { "?" } else { "" }
603            )
604        }
605        crate::schema::FieldType::Any => "any".to_string(),
606    }
607}
608
609#[cfg(test)]
610mod tests {
611    use super::*;
612    use crate::schema::FieldType;
613
614    // -- Tokenizer -----------------------------------------------------------
615
616    #[test]
617    fn tokenizer_skips_whitespace_and_comments() {
618        let toks = tokenize("  # a comment\n  root  # trailing\nX").unwrap();
619        let kinds: Vec<&str> = toks
620            .iter()
621            .map(|t| {
622                if t.text.is_empty() {
623                    "eof"
624                } else {
625                    t.text.as_str()
626                }
627            })
628            .collect();
629        assert_eq!(kinds, vec!["root", "X", "eof"]);
630    }
631
632    #[test]
633    fn tokenizer_rejects_literal_control_character_in_string() {
634        let err = parse_schema("record R {\n    \"\x01\": string,\n}\nroot R\n").unwrap_err();
635        assert_eq!(err.code, "parse.control-character");
636        assert_eq!(err.path, "$");
637        assert!(err.message.contains("control character U+0001 in string"));
638    }
639
640    #[test]
641    fn tokenizer_allows_escaped_control_characters_and_printable_strings() {
642        let schema = parse_schema(
643            "record R {\n    \"hello\\nworld\": string,\n    \"foo\\tbar\": integer,\n}\nroot R\n",
644        )
645        .unwrap();
646        assert_eq!(schema.root().name, "R");
647    }
648
649    #[test]
650    fn tokenizer_handles_string_escapes() {
651        let toks = tokenize(r#""a \"quoted\" b\\c""#).unwrap();
652        assert_eq!(toks[0].kind, TokKind::String);
653        assert_eq!(unquote(&toks[0].text), "a \"quoted\" b\\c");
654    }
655
656    #[test]
657    fn tokenizer_handles_numbers_int_and_decimal() {
658        let toks = tokenize("3 -4 2.5 -1.25").unwrap();
659        let texts: Vec<&str> = toks[..4].iter().map(|t| t.text.as_str()).collect();
660        assert_eq!(texts, vec!["3", "-4", "2.5", "-1.25"]);
661        assert!(toks[..4].iter().all(|t| t.kind == TokKind::Number));
662    }
663
664    #[test]
665    fn tokenizer_handles_punctuation_and_names() {
666        let toks = tokenize("{}[]:,?record_1").unwrap();
667        let texts: Vec<&str> = toks.iter().map(|t| t.text.as_str()).collect();
668        assert_eq!(
669            texts,
670            vec!["{", "}", "[", "]", ":", ",", "?", "record_1", ""]
671        );
672    }
673
674    #[test]
675    fn tokenizer_rejects_unexpected_character() {
676        let err = tokenize("record X { \"a\": string } root X\n@").unwrap_err();
677        assert!(err.to_string().contains("unexpected character"));
678        assert!(err.to_string().contains("'@'"));
679    }
680
681    #[test]
682    fn tokenizer_rejects_unexpected_character_even_when_a_later_match_exists() {
683        // The regex search is unanchored over the remaining suffix -- an
684        // invalid leading character must still be reported at its own
685        // position, not skipped over in favor of a later match (e.g. the
686        // `x` in "@x" must not cause the tokenizer to silently resume at
687        // position 1).
688        let err = tokenize("@x").unwrap_err();
689        assert!(err.to_string().contains("unexpected character"));
690        assert!(err.to_string().contains("at 0"));
691    }
692
693    // -- Parser: minimal valid schema ----------------------------------------
694
695    #[test]
696    fn parses_minimal_schema_one_record_and_root() {
697        let schema = parse_schema(r#"record X { "a": string } root X"#).unwrap();
698        assert_eq!(schema.root().name, "X");
699        let rec = schema.env().get("X").unwrap();
700        let f = rec.field("a").unwrap();
701        assert_eq!(f.min, 1);
702        assert_eq!(f.max, Some(1));
703        assert_eq!(f.ty, FieldType::Scalar(crate::schema::STRING));
704    }
705
706    // -- Parser: cardinality variants -----------------------------------------
707
708    #[test]
709    fn cardinality_variants() {
710        let schema = parse_schema(
711            r#"record X {
712                "a" [2]: string,
713                "b" [1,3]: string,
714                "c" [2,]: string,
715                "d" [,5]: string,
716                "e": string,
717            }
718            root X"#,
719        )
720        .unwrap();
721        let rec = schema.env().get("X").unwrap();
722        assert_eq!(
723            (rec.field("a").unwrap().min, rec.field("a").unwrap().max),
724            (2, Some(2))
725        );
726        assert_eq!(
727            (rec.field("b").unwrap().min, rec.field("b").unwrap().max),
728            (1, Some(3))
729        );
730        assert_eq!(
731            (rec.field("c").unwrap().min, rec.field("c").unwrap().max),
732            (2, None)
733        );
734        assert_eq!(
735            (rec.field("d").unwrap().min, rec.field("d").unwrap().max),
736            (0, Some(5))
737        );
738        assert_eq!(
739            (rec.field("e").unwrap().min, rec.field("e").unwrap().max),
740            (1, Some(1))
741        );
742    }
743
744    #[test]
745    fn cardinality_empty_brackets_is_an_error() {
746        let err = parse_schema(r#"record X { "a" []: string } root X"#).unwrap_err();
747        assert!(err.to_string().contains("empty cardinality"));
748    }
749
750    #[test]
751    fn cardinality_decimal_is_an_error() {
752        let err = parse_schema(r#"record X { "a" [2.5]: string } root X"#).unwrap_err();
753        assert!(err.to_string().contains("whole number"));
754    }
755
756    // -- Parser: scalar with/without `?`, Ref --------------------------------
757
758    #[test]
759    fn scalar_type_with_and_without_nullable() {
760        let schema = parse_schema(r#"record X { "a": integer, "b": integer? } root X"#).unwrap();
761        let rec = schema.env().get("X").unwrap();
762        assert_eq!(
763            rec.field("a").unwrap().ty,
764            FieldType::Scalar(crate::schema::INTEGER)
765        );
766        assert_eq!(
767            rec.field("b").unwrap().ty,
768            FieldType::Scalar(crate::schema::nullable(crate::schema::INTEGER))
769        );
770    }
771
772    #[test]
773    fn ref_type_resolves_across_records() {
774        let schema = parse_schema(
775            r#"record Child { "v": string }
776               record Parent { "c": Child }
777               root Parent"#,
778        )
779        .unwrap();
780        let rec = schema.env().get("Parent").unwrap();
781        assert_eq!(
782            rec.field("c").unwrap().ty,
783            FieldType::Ref(Ref::new("Child"))
784        );
785    }
786
787    #[test]
788    fn ref_type_rejects_nullable_marker() {
789        let err = parse_schema(
790            r#"record Child { "v": string }
791               record Parent { "c": Child? }
792               root Parent"#,
793        )
794        .unwrap_err();
795        assert!(err.to_string().contains("cannot apply to the reference"));
796    }
797
798    // -- Parser: unknown Ref target / duplicate field label (reuses #6) -----
799
800    #[test]
801    fn unknown_ref_target_is_caught() {
802        let err = parse_schema(r#"record X { "a": Missing } root X"#).unwrap_err();
803        assert!(err.to_string().contains("unknown type"));
804        assert!(err.to_string().contains("Missing"));
805    }
806
807    #[test]
808    fn duplicate_field_label_is_caught() {
809        let err = parse_schema(r#"record X { "a": string, "a": integer } root X"#).unwrap_err();
810        assert!(err.to_string().contains("duplicate field label"));
811    }
812
813    #[test]
814    fn duplicate_record_definition_is_caught() {
815        let err = parse_schema(r#"record X { "a": string } record X { "b": string } root X"#)
816            .unwrap_err();
817        assert!(err.to_string().contains("duplicate definition"));
818    }
819
820    #[test]
821    fn record_cannot_be_named_a_reserved_scalar_name() {
822        let err = parse_schema(r#"record string { "a": string } root string"#).unwrap_err();
823        assert!(err.to_string().contains("reserved scalar name"));
824    }
825
826    // -- Parser: malformed input at the right position -----------------------
827
828    #[test]
829    fn malformed_top_level_keyword_reports_position() {
830        let err = parse_schema("bogus X").unwrap_err();
831        assert!(err.to_string().contains("expected 'record' or 'root'"));
832        assert!(err.to_string().contains(" at 0"));
833    }
834
835    #[test]
836    fn missing_root_is_an_error() {
837        let err = parse_schema(r#"record X { "a": string }"#).unwrap_err();
838        assert!(err.to_string().contains("must declare a root"));
839    }
840
841    #[test]
842    fn root_name_must_be_a_name_token() {
843        let err = parse_schema(r#"record X { "a": string } root 5"#).unwrap_err();
844        assert!(err.to_string().contains("expected a name"));
845    }
846
847    #[test]
848    fn cardinality_rejects_a_negative_number() {
849        let err = parse_schema(r#"record X { "a" [-1]: string } root X"#).unwrap_err();
850        assert!(err.to_string().contains("non-negative whole number"));
851    }
852
853    #[test]
854    fn missing_field_colon_reports_position() {
855        let err = parse_schema(r#"record X { "a" string } root X"#).unwrap_err();
856        assert!(err.to_string().contains("expected \":\""));
857    }
858
859    #[test]
860    fn field_label_must_be_quoted() {
861        let err = parse_schema(r#"record X { a: string } root X"#).unwrap_err();
862        assert!(err.to_string().contains("expected a quoted field name"));
863    }
864
865    #[test]
866    fn type_position_rejects_non_name_token() {
867        let err = parse_schema(r#"record X { "a": 5 } root X"#).unwrap_err();
868        assert!(
869            err.to_string()
870                .contains("expected a scalar name or a reference")
871        );
872    }
873
874    // -- The `any` keyword: real support --------------------------------------
875
876    #[test]
877    fn any_as_field_type_parses_to_the_any_field_type() {
878        let schema = parse_schema(r#"record X { "a": any } root X"#).unwrap();
879        let rec = schema.env().get("X").unwrap();
880        assert_eq!(rec.field("a").unwrap().ty, FieldType::Any);
881    }
882
883    #[test]
884    fn any_with_nullable_marker_is_redundant_error() {
885        let err = parse_schema(r#"record X { "a": any? } root X"#).unwrap_err();
886        let msg = err.to_string();
887        assert!(msg.contains("already includes null"));
888        assert!(msg.contains("redundant"));
889    }
890
891    #[test]
892    fn any_round_trips_through_to_osd() {
893        let src = r#"record X { "a": any } root X"#;
894        let schema = parse_schema(src).unwrap();
895        let rendered = to_osd(&schema, None);
896        assert_eq!(rendered, "record X { \"a\": any } root X\n");
897        let reparsed = parse_schema(&rendered).unwrap();
898        assert_eq!(reparsed, schema);
899    }
900
901    #[test]
902    fn any_as_record_name_is_rejected_as_reserved() {
903        let err = parse_schema(r#"record any { "a": string } root any"#).unwrap_err();
904        assert!(err.to_string().contains("reserved type name"));
905        assert!(err.to_string().contains("cannot be used as a record name"));
906    }
907
908    // -- Tests covering every one of the 12 spec schema error codes ----------
909
910    #[test]
911    fn record_name_must_be_a_name_token() {
912        let err = parse_schema(r#"record 123 { "a": string } root X"#).unwrap_err();
913        assert_eq!(err.code, "parse.unexpected-token");
914    }
915
916    #[test]
917    fn type_position_rejects_punctuation_token() {
918        let err = parse_schema(r#"record X { "a": : } root X"#).unwrap_err();
919        assert_eq!(err.code, "parse.unexpected-token");
920    }
921    #[test]
922    fn test_code_schema_no_root() {
923        let err = parse_schema(r#"record X { "a": string }"#).unwrap_err();
924        assert_eq!(err.code, "schema.no-root");
925        assert_eq!(err.path, "$");
926    }
927
928    #[test]
929    fn test_code_schema_duplicate_root() {
930        let src = "record R { \"a\": string, }
931record S { \"b\": string, }
932root R
933root S
934";
935        let err = parse_schema(src).unwrap_err();
936        assert_eq!(err.code, "schema.duplicate-root");
937        assert_eq!(err.path, "$");
938    }
939
940    #[test]
941    fn test_code_schema_unknown_type() {
942        let err = parse_schema(r#"record X { "a": Missing } root X"#).unwrap_err();
943        assert_eq!(err.code, "schema.unknown-type");
944        assert_eq!(err.path, "X.a");
945
946        let err_root = parse_schema(r#"record X { "a": string } root Missing"#).unwrap_err();
947        assert_eq!(err_root.code, "schema.unknown-type");
948        assert_eq!(err_root.path, "$");
949    }
950
951    #[test]
952    fn test_code_schema_duplicate_record() {
953        let err = parse_schema(r#"record X { "a": string } record X { "b": string } root X"#)
954            .unwrap_err();
955        assert_eq!(err.code, "schema.duplicate-record");
956        assert_eq!(err.path, "X");
957    }
958
959    #[test]
960    fn test_code_schema_duplicate_field() {
961        let err = parse_schema(r#"record X { "a": string, "a": integer } root X"#).unwrap_err();
962        assert_eq!(err.code, "schema.duplicate-field");
963        assert_eq!(err.path, "X");
964    }
965
966    #[test]
967    fn test_code_schema_reserved_name() {
968        let err_scalar = parse_schema(r#"record string { "a": string } root string"#).unwrap_err();
969        assert_eq!(err_scalar.code, "schema.reserved-name");
970        assert_eq!(err_scalar.path, "string");
971
972        let err_any = parse_schema(r#"record any { "a": string } root any"#).unwrap_err();
973        assert_eq!(err_any.code, "schema.reserved-name");
974        assert_eq!(err_any.path, "any");
975    }
976
977    #[test]
978    fn test_code_schema_invalid_cardinality() {
979        let err_neg = parse_schema(r#"record X { "a" [-1]: string } root X"#).unwrap_err();
980        assert_eq!(err_neg.code, "schema.invalid-cardinality");
981        assert_eq!(err_neg.path, "X.a");
982
983        let err_inverted = parse_schema(r#"record X { "a" [3, 1]: string } root X"#).unwrap_err();
984        assert_eq!(err_inverted.code, "schema.invalid-cardinality");
985        assert_eq!(err_inverted.path, "X.a");
986    }
987
988    // Issue #158 (spec Sec5.5, updated 2026-08-24): [0,0] is now a
989    // normative error, redundant with not declaring the field at all --
990    // reuses `schema.invalid-cardinality`, no new code.
991    #[test]
992    fn test_code_schema_invalid_cardinality_zero_zero() {
993        let err = parse_schema(r#"record R { "a" [0,0]: string } root R"#).unwrap_err();
994        assert_eq!(err.code, "schema.invalid-cardinality");
995        assert_eq!(err.path, "R.a");
996
997        // [0,1] and [0,] (unbounded) remain legal -- only the exact [0,0]
998        // spelling is rejected.
999        assert!(parse_schema(r#"record R { "a" [0,1]: string } root R"#).is_ok());
1000        assert!(parse_schema(r#"record R { "a" [0,]: string } root R"#).is_ok());
1001    }
1002
1003    // Issue #163 (spec Sec5.4, updated 2026-08-29): an empty-string field
1004    // label is a normative error, path is the enclosing record -- same
1005    // convention `schema.unquoted-label` uses.
1006    #[test]
1007    fn test_code_schema_empty_label() {
1008        let err = parse_schema(r#"record R { "": string } root R"#).unwrap_err();
1009        assert_eq!(err.code, "schema.empty-label");
1010        assert_eq!(err.path, "R");
1011    }
1012
1013    // Issue #166 (spec Sec5.4, updated 2026-08-29): '[' or ']' anywhere in
1014    // a field label is a normative error -- both a matched-pair shape
1015    // ("a[1]") and a lone, unmatched ']' ("total]") are rejected, since
1016    // the rule is about the character vocabulary, not a specific pattern.
1017    #[test]
1018    fn test_code_schema_bracket_in_label() {
1019        let err = parse_schema(r#"record R { "a[1]": string } root R"#).unwrap_err();
1020        assert_eq!(err.code, "schema.bracket-in-label");
1021        assert_eq!(err.path, "R");
1022
1023        let err_lone = parse_schema(r#"record R { "total]": string } root R"#).unwrap_err();
1024        assert_eq!(err_lone.code, "schema.bracket-in-label");
1025        assert_eq!(err_lone.path, "R");
1026    }
1027
1028    #[test]
1029    fn cardinality_overflow_is_an_error() {
1030        let err = parse_schema(
1031            r#"record X { "a" [999999999999999999999999999999999999999]: string } root X"#,
1032        )
1033        .unwrap_err();
1034        assert_eq!(err.code, "schema.non-integer-cardinality");
1035        assert_eq!(err.path, "X.a");
1036    }
1037
1038    #[test]
1039    fn test_code_schema_non_integer_cardinality() {
1040        let err = parse_schema(r#"record X { "a" [1.5]: string } root X"#).unwrap_err();
1041        assert_eq!(err.code, "schema.non-integer-cardinality");
1042        assert_eq!(err.path, "X.a");
1043    }
1044
1045    #[test]
1046    fn test_code_schema_empty_cardinality() {
1047        let err = parse_schema(r#"record X { "a" []: string } root X"#).unwrap_err();
1048        assert_eq!(err.code, "schema.empty-cardinality");
1049        assert_eq!(err.path, "X.a");
1050    }
1051
1052    #[test]
1053    fn test_code_schema_unquoted_label() {
1054        let err = parse_schema(r#"record X { a: string } root X"#).unwrap_err();
1055        assert_eq!(err.code, "schema.unquoted-label");
1056        assert_eq!(err.path, "X");
1057    }
1058
1059    #[test]
1060    fn test_code_schema_quoted_type() {
1061        let err = parse_schema(r#"record X { "a": "string" } root X"#).unwrap_err();
1062        assert_eq!(err.code, "schema.quoted-type");
1063        assert_eq!(err.path, "X");
1064    }
1065
1066    #[test]
1067    fn test_code_schema_nullable_ref() {
1068        let err = parse_schema(
1069            r#"record Child { "v": string }
1070               record Parent { "c": Child? }
1071               root Parent"#,
1072        )
1073        .unwrap_err();
1074        assert_eq!(err.code, "schema.nullable-ref");
1075        assert_eq!(err.path, "Parent.c");
1076    }
1077
1078    #[test]
1079    fn test_code_schema_nullable_any() {
1080        let err = parse_schema(r#"record X { "a": any? } root X"#).unwrap_err();
1081        assert_eq!(err.code, "schema.nullable-any");
1082        assert_eq!(err.path, "X.a");
1083    }
1084
1085    // -- Comments interleaved with real grammar ------------------------------
1086
1087    #[test]
1088    fn comments_are_ignored_between_tokens() {
1089        let schema = parse_schema(
1090            "# leading comment\nrecord X { # field list\n  \"a\": string # trailing\n} root X # done",
1091        )
1092        .unwrap();
1093        assert_eq!(schema.root().name, "X");
1094    }
1095
1096    // -- to_osd ----------------------------------------------------------------
1097
1098    #[test]
1099    fn to_osd_pretty_round_trips_through_parse_schema() {
1100        let src = r#"
1101            record X {
1102                "a": string,
1103                "b" [0,1]: integer?,
1104                "c" [2,]: X,
1105            }
1106            root X
1107        "#;
1108        let schema = parse_schema(src).unwrap();
1109        let rendered = to_osd(&schema, Some(4));
1110        assert_eq!(
1111            rendered,
1112            "record X {\n    \"a\": string,\n    \"b\" [0,1]: integer?,\n    \
1113             \"c\" [2,]: X,\n}\nroot X\n"
1114        );
1115        let reparsed = parse_schema(&rendered).unwrap();
1116        assert_eq!(reparsed, schema);
1117    }
1118
1119    #[test]
1120    fn to_osd_compact_round_trips_through_parse_schema() {
1121        let src = r#"record X { "a": string, "b" [0,1]: integer? } root X"#;
1122        let schema = parse_schema(src).unwrap();
1123        let rendered = to_osd(&schema, None);
1124        assert_eq!(
1125            rendered,
1126            "record X { \"a\": string, \"b\" [0,1]: integer? } root X\n"
1127        );
1128        let reparsed = parse_schema(&rendered).unwrap();
1129        assert_eq!(reparsed, schema);
1130    }
1131
1132    #[test]
1133    fn to_osd_renders_exact_cardinality_without_brackets_comma() {
1134        let src = r#"record X { "a" [2]: string } root X"#;
1135        let schema = parse_schema(src).unwrap();
1136        assert_eq!(
1137            to_osd(&schema, None),
1138            "record X { \"a\" [2]: string } root X\n"
1139        );
1140    }
1141
1142    #[test]
1143    fn to_osd_renders_ref_type_bare() {
1144        let src = r#"record Leaf { "v": string } record X { "child": Leaf } root X"#;
1145        let schema = parse_schema(src).unwrap();
1146        let rendered = to_osd(&schema, None);
1147        assert!(rendered.contains("\"child\": Leaf"));
1148    }
1149
1150    #[test]
1151    fn to_osd_empty_record_field_list_renders_braces() {
1152        let schema = Schema::new(
1153            Ref::new("X"),
1154            IndexMap::from([("X".to_string(), Record::new(vec![]).unwrap())]),
1155        )
1156        .unwrap();
1157        assert_eq!(to_osd(&schema, None), "record X {  } root X\n");
1158        assert_eq!(to_osd(&schema, Some(2)), "record X {\n}\nroot X\n");
1159    }
1160
1161    #[test]
1162    fn test_osd_writer_quote_backslash_escaping() {
1163        use crate::schema::{Field, INTEGER, Record, Ref, Schema};
1164        use indexmap::IndexMap;
1165
1166        let test_labels = ["a\"b", "a\\b", "a\"b\\c", r#"quote"and\backslash"together"#];
1167        for label in test_labels {
1168            let field = Field::required(label, INTEGER).unwrap();
1169            let record = Record::new(vec![field]).unwrap();
1170            let mut env = IndexMap::new();
1171            env.insert("Root".to_string(), record);
1172            let schema = Schema::new(Ref::new("Root"), env).unwrap();
1173
1174            // Test pretty OSD
1175            let osd_pretty = to_osd(&schema, Some(4));
1176            let parsed_pretty = parse_schema(&osd_pretty).expect("pretty OSD should re-parse");
1177            let rec_pretty = parsed_pretty.env().get("Root").unwrap();
1178            assert_eq!(rec_pretty.fields()[0].label, label);
1179
1180            // Test compact OSD
1181            let osd_compact = to_osd(&schema, None);
1182            let parsed_compact = parse_schema(&osd_compact).expect("compact OSD should re-parse");
1183            let rec_compact = parsed_compact.env().get("Root").unwrap();
1184            assert_eq!(rec_compact.fields()[0].label, label);
1185        }
1186    }
1187}