1use 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
36static 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#[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
74fn 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
135fn 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
154struct 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 fn parse_record(&mut self) -> Result<(String, Record, usize), SchemaError> {
277 self.next_tok(); 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 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 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 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
519pub fn parse_schema(text: &str) -> Result<Schema, SchemaError> {
521 let toks = tokenize(text)?;
522 Parser::new(toks).parse_schema()
523}
524
525pub 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 #[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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 #[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 #[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 #[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 #[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 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 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}