1use 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
37static 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#[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
75fn 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
126fn 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
134fn 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 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
184fn 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
203struct 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 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 fn parse_record(&mut self) -> Result<(String, Record, usize), SchemaError> {
333 self.next_tok(); 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 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 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 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
575pub fn parse_schema(text: &str) -> Result<Schema, SchemaError> {
577 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
590pub 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 #[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 assert_eq!(err.path, "2:5");
750 assert!(err.message.contains("control character U+0001 in string"));
751 }
752
753 fn lex_error(text: &str) -> (String, String) {
757 let e = parse_schema(text).unwrap_err();
758 (e.path, e.code)
759 }
760
761 #[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 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 assert_eq!(
792 lex_error("record R {\n @\n"),
793 pos("2:3", "parse.unexpected-token")
794 );
795 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 assert_eq!(
806 lex_error("record R {\n \"ab\ncd"),
807 pos("2:3", "parse.control-character")
808 );
809 assert_eq!(
812 lex_error("record R {\n \"a\\\nb\": string,\n}\nroot R\n"),
813 pos("2:3", "parse.control-character")
814 );
815 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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 #[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 #[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 #[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 #[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 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 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}