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