1use std::collections::{HashMap, HashSet};
22use std::fmt;
23
24use memchr::memchr;
25
26use crate::parser::{self, ParserOptions};
27use crate::value::{Constraints, Value};
28
29pub const SYNXL_VERSION: u32 = 1;
33
34pub const MAX_SYNXL_RECORD_BYTES: usize = 16 * 1024 * 1024;
39
40pub const MAX_SYNXL_FIELDS: usize = 4_096;
42
43pub const MAX_SYNXL_FIELD_NAME_BYTES: usize = 255;
45
46pub const MAX_SYNXL_FIELD_LISTS: usize = 65_536;
48
49pub const MAX_SYNXL_RECORDS: usize = 16_777_216;
51
52const MAX_WRITE_DEPTH: usize = parser::MAX_PARSE_NESTING_DEPTH;
60
61const FLOAT_EXPANSION_PRECISION: usize = 1_100;
65
66#[derive(Debug, Clone, PartialEq)]
70pub struct FieldDecl {
71 pub name: String,
73 pub type_hint: Option<String>,
76 pub constraints: Constraints,
79 pub block: bool,
81}
82
83impl FieldDecl {
84 pub fn new(name: impl Into<String>) -> Self {
86 Self {
87 name: name.into(),
88 type_hint: None,
89 constraints: Constraints::default(),
90 block: false,
91 }
92 }
93
94 pub fn new_block(name: impl Into<String>) -> Self {
96 Self { block: true, ..Self::new(name) }
97 }
98
99 pub fn type_name(&self) -> Option<&str> {
101 self.type_hint
102 .as_deref()
103 .or(self.constraints.type_name.as_deref())
104 }
105}
106
107#[derive(Debug, Clone, PartialEq)]
109pub struct FieldList {
110 fields: Vec<FieldDecl>,
111 arity: usize,
112 source: String,
113 pub line: usize,
115}
116
117impl FieldList {
118 pub fn new(fields: Vec<FieldDecl>) -> Self {
123 let arity = fields.iter().filter(|f| !f.block).count();
124 Self { fields, arity, source: String::new(), line: 0 }
125 }
126
127 pub fn source(&self) -> &str {
136 &self.source
137 }
138
139 pub fn fields(&self) -> &[FieldDecl] {
141 &self.fields
142 }
143
144 pub fn arity(&self) -> usize {
147 self.arity
148 }
149
150 pub fn get(&self, name: &str) -> Option<&FieldDecl> {
152 self.fields.iter().find(|f| f.name == name)
153 }
154}
155
156#[derive(Debug, Clone, Copy, PartialEq, Eq)]
161pub enum SynxlErrorKind {
162 MissingPrologue,
164 UnsupportedVersion,
168 UnknownDirective,
171 NoFieldList,
173 MalformedFieldList,
175 DuplicateField,
177 MarkerChain,
180 NonDeterministicHint,
182 BlockWithType,
184 ZeroArity,
186 Unwritable,
192 LimitExceeded,
194}
195
196impl SynxlErrorKind {
197 pub fn as_str(self) -> &'static str {
198 match self {
199 SynxlErrorKind::MissingPrologue => "MissingPrologue",
200 SynxlErrorKind::UnsupportedVersion => "UnsupportedVersion",
201 SynxlErrorKind::UnknownDirective => "UnknownDirective",
202 SynxlErrorKind::NoFieldList => "NoFieldList",
203 SynxlErrorKind::MalformedFieldList => "MalformedFieldList",
204 SynxlErrorKind::DuplicateField => "DuplicateField",
205 SynxlErrorKind::MarkerChain => "MarkerChain",
206 SynxlErrorKind::NonDeterministicHint => "NonDeterministicHint",
207 SynxlErrorKind::BlockWithType => "BlockWithType",
208 SynxlErrorKind::ZeroArity => "ZeroArity",
209 SynxlErrorKind::Unwritable => "Unwritable",
210 SynxlErrorKind::LimitExceeded => "LimitExceeded",
211 }
212 }
213}
214
215impl fmt::Display for SynxlErrorKind {
216 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
217 f.write_str(self.as_str())
218 }
219}
220
221#[derive(Debug, Clone, PartialEq, Eq)]
223pub struct SynxlError {
224 pub kind: SynxlErrorKind,
225 pub line: usize,
226 pub message: String,
227}
228
229impl SynxlError {
230 pub fn new(kind: SynxlErrorKind, line: usize, message: impl Into<String>) -> Self {
237 Self { kind, line, message: message.into() }
238 }
239}
240
241impl fmt::Display for SynxlError {
242 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
243 write!(f, "SYNXL {} at line {}: {}", self.kind, self.line, self.message)
244 }
245}
246
247impl std::error::Error for SynxlError {}
248
249#[derive(Debug, Clone, Copy, PartialEq, Eq)]
251pub enum DiagnosticKind {
252 MissingFields,
254 ExtraFields,
256 CastFailed,
258 UnknownBlockKey,
260 BlockFieldNotDeclared,
262 OrphanBlockLine,
265 ConstraintViolation,
267 RecordTruncated,
269}
270
271impl DiagnosticKind {
272 pub fn as_str(self) -> &'static str {
273 match self {
274 DiagnosticKind::MissingFields => "MissingFields",
275 DiagnosticKind::ExtraFields => "ExtraFields",
276 DiagnosticKind::CastFailed => "CastFailed",
277 DiagnosticKind::UnknownBlockKey => "UnknownBlockKey",
278 DiagnosticKind::BlockFieldNotDeclared => "BlockFieldNotDeclared",
279 DiagnosticKind::OrphanBlockLine => "OrphanBlockLine",
280 DiagnosticKind::ConstraintViolation => "ConstraintViolation",
281 DiagnosticKind::RecordTruncated => "RecordTruncated",
282 }
283 }
284}
285
286impl fmt::Display for DiagnosticKind {
287 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
288 f.write_str(self.as_str())
289 }
290}
291
292#[derive(Debug, Clone, PartialEq, Eq)]
295pub struct Diagnostic {
296 pub record_index: usize,
297 pub line: usize,
298 pub kind: DiagnosticKind,
299 pub message: String,
300}
301
302impl fmt::Display for Diagnostic {
303 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
304 write!(
305 f,
306 "{} (record {}, line {}): {}",
307 self.kind, self.record_index, self.line, self.message
308 )
309 }
310}
311
312#[derive(Debug, Clone, Default)]
316pub struct SynxlOptions {
317 pub validate: bool,
320}
321
322#[derive(Debug, Clone, PartialEq)]
326pub struct SynxlRecord {
327 pub index: usize,
329 pub line: usize,
331 pub field_list: usize,
333 pub value: Value,
335 pub diagnostics: Vec<Diagnostic>,
337}
338
339#[derive(Debug, Clone, PartialEq)]
341pub struct SynxlDocument {
342 pub version: u32,
344 pub records: Vec<Value>,
346 pub field_lists: Vec<FieldList>,
348 pub record_field_lists: Vec<usize>,
350 pub record_lines: Vec<usize>,
352 pub diagnostics: Vec<Diagnostic>,
354}
355
356impl SynxlDocument {
357 pub fn len(&self) -> usize {
358 self.records.len()
359 }
360
361 pub fn is_empty(&self) -> bool {
362 self.records.is_empty()
363 }
364
365 pub fn field_list_for(&self, index: usize) -> Option<&FieldList> {
367 self.record_field_lists
368 .get(index)
369 .and_then(|i| self.field_lists.get(*i))
370 }
371
372 pub fn to_json(&self) -> String {
374 records_to_json_array(&self.records)
375 }
376
377 pub fn to_ndjson(&self) -> String {
379 records_to_ndjson(&self.records)
380 }
381
382 pub fn to_synxl(&self) -> Result<String, SynxlError> {
387 write_document(self)
388 }
389}
390
391pub fn records_to_json_array(records: &[Value]) -> String {
393 let mut out = String::with_capacity(records.len() * 64 + 2);
394 out.push('[');
395 for (i, rec) in records.iter().enumerate() {
396 if i > 0 {
397 out.push(',');
398 }
399 crate::write_json(&mut out, rec);
400 }
401 out.push(']');
402 out
403}
404
405pub fn records_to_ndjson(records: &[Value]) -> String {
408 let mut out = String::with_capacity(records.len() * 64);
409 for rec in records {
410 crate::write_json(&mut out, rec);
411 out.push('\n');
412 }
413 out
414}
415
416#[derive(Debug, Clone, Copy)]
424struct Line {
425 start: usize,
427 end: usize,
429 no: usize,
431}
432
433impl Line {
434 #[inline]
436 fn raw<'t>(&self, text: &'t str) -> &'t str {
437 &text[self.start..self.end]
438 }
439
440 #[inline]
441 fn indent(&self, text: &str) -> usize {
442 let raw = self.raw(text);
443 raw.len() - raw.trim_start().len()
444 }
445}
446
447enum LineKind {
449 Skip,
452 Record,
454}
455
456struct RecordFrame<'t> {
462 line: &'t str,
464 line_no: usize,
466 block: &'t str,
468 block_first_no: usize,
470 truncated_total: Option<usize>,
472}
473
474#[derive(Debug)]
481struct ReaderCore {
482 pos: usize,
484 line_no: usize,
486 pending: Option<Line>,
488 pending_diagnostics: Vec<Diagnostic>,
491 in_block_comment: bool,
492 field_lists: Vec<FieldList>,
493 current: Option<usize>,
494 record_index: usize,
495 version: u32,
496 done: bool,
497 opts: SynxlOptions,
498}
499
500impl ReaderCore {
501 fn empty(opts: SynxlOptions) -> Self {
503 Self {
504 pos: 0,
505 line_no: 1,
506 pending: None,
507 pending_diagnostics: Vec::new(),
508 in_block_comment: false,
509 field_lists: Vec::new(),
510 current: None,
511 record_index: 0,
512 version: 0,
513 done: false,
514 opts,
515 }
516 }
517
518 fn start(text: &str, opts: SynxlOptions) -> Result<Self, SynxlError> {
520 let mut core = Self {
521 pos: if text.starts_with('\u{feff}') {
525 '\u{feff}'.len_utf8()
526 } else {
527 0
528 },
529 line_no: 1,
530 pending: None,
531 pending_diagnostics: Vec::new(),
532 in_block_comment: false,
533 field_lists: Vec::new(),
534 current: None,
535 record_index: 0,
536 version: 0,
537 done: false,
538 opts,
539 };
540 core.read_prologue(text)?;
541 Ok(core)
542 }
543
544 fn read_line(&mut self, text: &str) -> Option<Line> {
546 if let Some(line) = self.pending.take() {
547 return Some(line);
548 }
549 let bytes = text.as_bytes();
550 if self.pos >= bytes.len() {
551 return None;
552 }
553 let start = self.pos;
554 let (mut end, next) = match memchr(b'\n', &bytes[start..]) {
555 Some(rel) => (start + rel, start + rel + 1),
556 None => (bytes.len(), bytes.len()),
557 };
558 if end > start && bytes[end - 1] == b'\r' {
560 end -= 1;
561 }
562 self.pos = next;
563 let no = self.line_no;
564 self.line_no += 1;
565 Some(Line { start, end, no })
566 }
567
568 fn next_item(&mut self, text: &str) -> Option<Result<SynxlRecord, SynxlError>> {
570 if self.done {
571 return None;
572 }
573 match self.next_record(text) {
574 Ok(Some(rec)) => Some(Ok(rec)),
575 Ok(None) => {
576 self.done = true;
577 None
578 }
579 Err(err) => {
580 self.done = true;
582 Some(Err(err))
583 }
584 }
585 }
586
587 fn prologue_skip(&mut self, trimmed: &str) -> bool {
592 if trimmed.is_empty() {
593 return true;
594 }
595 if trimmed == "###" {
597 self.in_block_comment = !self.in_block_comment;
598 return true;
599 }
600 if self.in_block_comment {
601 return true;
602 }
603 trimmed.starts_with('#') || trimmed.starts_with("//")
604 }
605
606 fn missing_prologue(last_line: usize) -> SynxlError {
608 SynxlError::new(
609 SynxlErrorKind::MissingPrologue,
610 last_line.max(1),
611 "document has no `!synxl <version>` prologue",
612 )
613 }
614
615 fn read_prologue(&mut self, text: &str) -> Result<(), SynxlError> {
617 while let Some(line) = self.read_line(text) {
618 let trimmed = line.raw(text).trim();
619 if self.prologue_skip(trimmed) {
620 continue;
621 }
622 self.version = parse_prologue(trimmed, line.no)?;
623 return Ok(());
624 }
625 Err(Self::missing_prologue(self.line_no.saturating_sub(1)))
626 }
627
628 fn note_orphan(&mut self, trimmed: &str, no: usize) {
630 self.pending_diagnostics.push(Diagnostic {
631 record_index: self.record_index,
632 line: no,
633 kind: DiagnosticKind::OrphanBlockLine,
634 message: format!("indented line `{}` has no open record; discarded", elide(trimmed)),
635 });
636 }
637
638 fn classify(&mut self, trimmed: &str, no: usize) -> Result<LineKind, SynxlError> {
644 if trimmed == "###" {
645 self.in_block_comment = !self.in_block_comment;
646 return Ok(LineKind::Skip);
647 }
648 if self.in_block_comment {
649 return Ok(LineKind::Skip);
650 }
651
652 if trimmed.starts_with('!') {
654 if let Some(rest) = trimmed.strip_prefix("!fields") {
655 if rest.is_empty() || starts_with_wsp(rest) {
656 let list = parse_field_list(rest.trim(), trimmed, no)?;
657 self.push_field_list(list)?;
658 return Ok(LineKind::Skip);
659 }
660 }
661 if let Some(rest) = trimmed.strip_prefix("!synxl") {
662 if rest.is_empty() || starts_with_wsp(rest) {
663 parse_prologue(trimmed, no)?;
667 return Ok(LineKind::Skip);
668 }
669 }
670 return Err(SynxlError::new(
675 SynxlErrorKind::UnknownDirective,
676 no,
677 format!(
678 "`{}` is neither a prologue nor a field list; a record starting with `!` must quote it",
679 elide(trimmed)
680 ),
681 ));
682 }
683 if trimmed.starts_with('#') || trimmed.starts_with("//") {
684 return Ok(LineKind::Skip);
685 }
686
687 Ok(LineKind::Record)
690 }
691
692 fn require_field_list(&self, no: usize) -> Result<usize, SynxlError> {
694 self.current.ok_or_else(|| {
695 SynxlError::new(
696 SynxlErrorKind::NoFieldList,
697 no,
698 "record line with no `!fields` in effect",
699 )
700 })
701 }
702
703 fn push_field_list(&mut self, list: FieldList) -> Result<(), SynxlError> {
705 if self.field_lists.len() >= MAX_SYNXL_FIELD_LISTS {
706 return Err(SynxlError::new(
707 SynxlErrorKind::LimitExceeded,
708 list.line,
709 format!("more than {} field lists in one document", MAX_SYNXL_FIELD_LISTS),
710 ));
711 }
712 self.field_lists.push(list);
713 self.current = Some(self.field_lists.len() - 1);
714 Ok(())
715 }
716
717 fn next_record(&mut self, text: &str) -> Result<Option<SynxlRecord>, SynxlError> {
719 loop {
720 let line = match self.read_line(text) {
721 Some(l) => l,
722 None => return Ok(None),
723 };
724 let trimmed = line.raw(text).trim();
725 if trimmed.is_empty() {
726 continue;
727 }
728 if self.in_block_comment {
733 if trimmed == "###" {
734 self.in_block_comment = false;
735 }
736 continue;
737 }
738 if line.indent(text) > 0 {
742 self.note_orphan(trimmed, line.no);
743 continue;
744 }
745 if let LineKind::Skip = self.classify(trimmed, line.no)? {
746 continue;
747 }
748
749 let fl_idx = self.require_field_list(line.no)?;
750
751 let mut block_start: Option<usize> = None;
755 let mut block_end: Option<usize> = None;
756 let mut block_first_no = 0usize;
757 loop {
758 let next = match self.read_line(text) {
759 Some(l) => l,
760 None => break,
761 };
762 if next.raw(text).trim().is_empty() {
763 continue;
764 }
765 if next.indent(text) == 0 {
766 self.pending = Some(next);
767 break;
768 }
769 if block_start.is_none() {
770 block_start = Some(next.start);
771 block_first_no = next.no;
772 }
773 block_end = Some(next.end);
774 }
775 let block_raw = match (block_start, block_end) {
779 (Some(s), Some(e)) => &text[s..e],
780 _ => "",
781 };
782
783 let mut record_line = line.raw(text);
786 let mut block_text = block_raw;
787 let total = record_line.len().saturating_add(block_text.len());
788 let mut truncated_total = None;
789 if total > MAX_SYNXL_RECORD_BYTES {
790 truncated_total = Some(total);
791 if record_line.len() >= MAX_SYNXL_RECORD_BYTES {
792 record_line = truncate_utf8(record_line, MAX_SYNXL_RECORD_BYTES);
793 block_text = "";
794 } else {
795 block_text =
796 truncate_utf8(block_text, MAX_SYNXL_RECORD_BYTES - record_line.len());
797 }
798 }
799
800 return self
801 .build(
802 fl_idx,
803 RecordFrame {
804 line: record_line,
805 line_no: line.no,
806 block: block_text,
807 block_first_no,
808 truncated_total,
809 },
810 )
811 .map(Some);
812 }
813 }
814
815 fn build(&mut self, fl_idx: usize, frame: RecordFrame<'_>) -> Result<SynxlRecord, SynxlError> {
820 let RecordFrame {
821 line: record_line,
822 line_no,
823 block: block_text,
824 block_first_no,
825 truncated_total,
826 } = frame;
827
828 let index = self.record_index;
829 self.record_index += 1;
830 let mut diagnostics: Vec<Diagnostic> = std::mem::take(&mut self.pending_diagnostics);
833 if let Some(total) = truncated_total {
834 diagnostics.push(Diagnostic {
835 record_index: index,
836 line: line_no,
837 kind: DiagnosticKind::RecordTruncated,
838 message: format!(
839 "record is {} bytes, truncated to the {} byte limit",
840 total, MAX_SYNXL_RECORD_BYTES
841 ),
842 });
843 }
844
845 let block_root = if block_text.trim().is_empty() {
848 None
849 } else {
850 let parsed = parser::parse_with(
853 block_text,
854 ParserOptions { directives: false, nondeterministic_hints: false },
855 );
856 if parsed.truncated {
862 diagnostics.push(Diagnostic {
863 record_index: index,
864 line: line_no,
865 kind: DiagnosticKind::RecordTruncated,
866 message: "block content exceeded a SYNX parser limit (§13) and was truncated"
867 .to_string(),
868 });
869 }
870 match parsed.root {
871 Value::Object(map) => Some(map),
872 _ => None,
873 }
874 };
875
876 let fl = &self.field_lists[fl_idx];
877 let mut obj: HashMap<String, Value> = HashMap::with_capacity(fl.fields.len());
878
879 if trim_wsp(record_line) == ";" {
884 for field in fl.fields.iter().filter(|f| !f.block) {
890 obj.insert(field.name.clone(), Value::Null);
891 }
892 } else {
893 let mut inline_fields = fl.fields.iter().filter(|f| !f.block);
895 let mut parts = PartSplitter::new(record_line);
896 let mut part_count = 0usize;
897 loop {
898 let part = match parts.next() {
899 Some(p) => p,
900 None => break,
901 };
902 part_count += 1;
903 match inline_fields.next() {
904 Some(field) => {
905 let value = cast_part(field, part, index, line_no, &mut diagnostics);
906 obj.insert(field.name.clone(), value);
907 }
908 None => {}
910 }
911 }
912 let mut missing = 0usize;
913 for field in inline_fields {
914 obj.insert(field.name.clone(), Value::Null);
915 missing += 1;
916 }
917 if missing > 0 {
918 diagnostics.push(Diagnostic {
919 record_index: index,
920 line: line_no,
921 kind: DiagnosticKind::MissingFields,
922 message: format!(
923 "record has {} inline part(s), field list declares {}; {} trailing field(s) set to null",
924 part_count,
925 fl.arity(),
926 missing
927 ),
928 });
929 } else if part_count > fl.arity() {
930 diagnostics.push(Diagnostic {
931 record_index: index,
932 line: line_no,
933 kind: DiagnosticKind::ExtraFields,
934 message: format!(
935 "record has {} inline part(s), field list declares {}; {} discarded",
936 part_count,
937 fl.arity(),
938 part_count - fl.arity()
939 ),
940 });
941 }
942 }
943
944 for field in fl.fields.iter().filter(|f| f.block) {
946 obj.insert(field.name.clone(), Value::Null);
947 }
948
949 let mut key_lines: Option<HashMap<&str, usize>> = None;
953
954 if let Some(map) = block_root {
957 let mut entries: Vec<(String, Value)> = map.into_iter().collect();
958 entries.sort_unstable_by(|a, b| a.0.cmp(&b.0));
959 for (key, value) in entries {
960 match fl.get(&key) {
961 Some(field) if field.block => {
962 obj.insert(field.name.clone(), value);
963 }
964 Some(_) => {
965 let at = key_lines
966 .get_or_insert_with(|| block_key_lines(block_text, block_first_no))
967 .get(key.as_str())
968 .copied()
969 .unwrap_or(line_no);
970 diagnostics.push(Diagnostic {
971 record_index: index,
972 line: at,
973 kind: DiagnosticKind::BlockFieldNotDeclared,
974 message: format!(
975 "block key `{}` matches a field that is not declared [block]; inline value kept",
976 key
977 ),
978 });
979 }
980 None => {
981 let at = key_lines
982 .get_or_insert_with(|| block_key_lines(block_text, block_first_no))
983 .get(key.as_str())
984 .copied()
985 .unwrap_or(line_no);
986 diagnostics.push(Diagnostic {
987 record_index: index,
988 line: at,
989 kind: DiagnosticKind::UnknownBlockKey,
990 message: format!("block key `{}` matches no declared field", key),
991 });
992 }
993 }
994 }
995 }
996
997 if self.opts.validate {
999 for field in fl.fields.iter() {
1000 if let Some(value) = obj.get(&field.name) {
1001 if let Some(msg) = check_constraints(&field.name, value, &field.constraints) {
1002 let at = if field.block {
1005 key_lines
1006 .get_or_insert_with(|| block_key_lines(block_text, block_first_no))
1007 .get(field.name.as_str())
1008 .copied()
1009 .unwrap_or(line_no)
1010 } else {
1011 line_no
1012 };
1013 diagnostics.push(Diagnostic {
1014 record_index: index,
1015 line: at,
1016 kind: DiagnosticKind::ConstraintViolation,
1017 message: msg,
1018 });
1019 }
1020 }
1021 }
1022 }
1023
1024 Ok(SynxlRecord {
1025 index,
1026 line: line_no,
1027 field_list: fl_idx,
1028 value: Value::Object(obj),
1029 diagnostics,
1030 })
1031 }
1032}
1033
1034#[derive(Debug)]
1056pub struct SynxlReader<'a> {
1057 text: &'a str,
1058 core: ReaderCore,
1059}
1060
1061impl<'a> SynxlReader<'a> {
1062 pub fn new(text: &'a str) -> Result<Self, SynxlError> {
1064 Self::with_options(text, SynxlOptions::default())
1065 }
1066
1067 pub fn with_options(text: &'a str, opts: SynxlOptions) -> Result<Self, SynxlError> {
1069 Ok(Self { text, core: ReaderCore::start(text, opts)? })
1070 }
1071
1072 pub fn version(&self) -> u32 {
1074 self.core.version
1075 }
1076
1077 pub fn field_lists(&self) -> &[FieldList] {
1079 &self.core.field_lists
1080 }
1081
1082 pub fn field_list(&self) -> Option<&FieldList> {
1084 self.core.current.map(|i| &self.core.field_lists[i])
1085 }
1086
1087 pub fn into_field_lists(self) -> Vec<FieldList> {
1089 self.core.field_lists
1090 }
1091
1092 pub fn trailing_diagnostics(&self) -> &[Diagnostic] {
1096 &self.core.pending_diagnostics
1097 }
1098}
1099
1100impl Iterator for SynxlReader<'_> {
1101 type Item = Result<SynxlRecord, SynxlError>;
1102
1103 fn next(&mut self) -> Option<Self::Item> {
1104 self.core.next_item(self.text)
1105 }
1106}
1107
1108#[derive(Debug)]
1129pub struct SynxlReaderOwned {
1130 text: String,
1131 core: ReaderCore,
1132}
1133
1134impl SynxlReaderOwned {
1135 pub fn new(text: String) -> Result<Self, SynxlError> {
1137 Self::with_options(text, SynxlOptions::default())
1138 }
1139
1140 pub fn with_options(text: String, opts: SynxlOptions) -> Result<Self, SynxlError> {
1142 let core = ReaderCore::start(&text, opts)?;
1143 Ok(Self { text, core })
1144 }
1145
1146 pub fn version(&self) -> u32 {
1148 self.core.version
1149 }
1150
1151 pub fn field_lists(&self) -> &[FieldList] {
1153 &self.core.field_lists
1154 }
1155
1156 pub fn field_list(&self) -> Option<&FieldList> {
1158 self.core.current.map(|i| &self.core.field_lists[i])
1159 }
1160
1161 pub fn into_field_lists(self) -> Vec<FieldList> {
1163 self.core.field_lists
1164 }
1165
1166 pub fn trailing_diagnostics(&self) -> &[Diagnostic] {
1169 &self.core.pending_diagnostics
1170 }
1171
1172 pub fn text(&self) -> &str {
1174 &self.text
1175 }
1176
1177 pub fn into_text(self) -> String {
1179 self.text
1180 }
1181}
1182
1183impl Iterator for SynxlReaderOwned {
1184 type Item = Result<SynxlRecord, SynxlError>;
1185
1186 fn next(&mut self) -> Option<Self::Item> {
1187 self.core.next_item(&self.text)
1188 }
1189}
1190
1191#[derive(Debug)]
1201pub enum SynxlStreamError {
1202 Format(SynxlError),
1204 Io(std::io::Error),
1206}
1207
1208impl SynxlStreamError {
1209 pub fn as_format(&self) -> Option<&SynxlError> {
1211 match self {
1212 SynxlStreamError::Format(e) => Some(e),
1213 SynxlStreamError::Io(_) => None,
1214 }
1215 }
1216
1217 pub fn as_io(&self) -> Option<&std::io::Error> {
1219 match self {
1220 SynxlStreamError::Io(e) => Some(e),
1221 SynxlStreamError::Format(_) => None,
1222 }
1223 }
1224}
1225
1226impl fmt::Display for SynxlStreamError {
1227 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1228 match self {
1229 SynxlStreamError::Format(e) => write!(f, "{}", e),
1230 SynxlStreamError::Io(e) => write!(f, "I/O error while reading SYNXL: {}", e),
1231 }
1232 }
1233}
1234
1235impl std::error::Error for SynxlStreamError {
1236 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
1237 match self {
1238 SynxlStreamError::Format(e) => Some(e),
1239 SynxlStreamError::Io(e) => Some(e),
1240 }
1241 }
1242}
1243
1244impl From<SynxlError> for SynxlStreamError {
1245 fn from(e: SynxlError) -> Self {
1246 SynxlStreamError::Format(e)
1247 }
1248}
1249
1250impl From<std::io::Error> for SynxlStreamError {
1251 fn from(e: std::io::Error) -> Self {
1252 SynxlStreamError::Io(e)
1253 }
1254}
1255
1256#[derive(Debug)]
1287pub struct SynxlStreamReader<R: std::io::BufRead> {
1288 inner: R,
1289 core: ReaderCore,
1290 scratch: Vec<u8>,
1292 cur: String,
1294 cur_no: usize,
1296 cur_dropped: usize,
1298 lookahead: Option<(String, usize, usize)>,
1300 line_no: usize,
1302 record: String,
1304 block: String,
1305 deferred: String,
1308 done: bool,
1309}
1310
1311impl<R: std::io::BufRead> SynxlStreamReader<R> {
1312 pub fn new(inner: R) -> Result<Self, SynxlStreamError> {
1314 Self::with_options(inner, SynxlOptions::default())
1315 }
1316
1317 pub fn with_options(inner: R, opts: SynxlOptions) -> Result<Self, SynxlStreamError> {
1319 let mut reader = Self {
1320 inner,
1321 core: ReaderCore::empty(opts),
1322 scratch: Vec::with_capacity(256),
1323 cur: String::with_capacity(256),
1324 cur_no: 0,
1325 cur_dropped: 0,
1326 lookahead: None,
1327 line_no: 0,
1328 record: String::new(),
1329 block: String::new(),
1330 deferred: String::new(),
1331 done: false,
1332 };
1333 reader.read_prologue()?;
1334 Ok(reader)
1335 }
1336
1337 pub fn version(&self) -> u32 {
1339 self.core.version
1340 }
1341
1342 pub fn field_lists(&self) -> &[FieldList] {
1344 &self.core.field_lists
1345 }
1346
1347 pub fn field_list(&self) -> Option<&FieldList> {
1349 self.core.current.map(|i| &self.core.field_lists[i])
1350 }
1351
1352 pub fn into_field_lists(self) -> Vec<FieldList> {
1354 self.core.field_lists
1355 }
1356
1357 pub fn trailing_diagnostics(&self) -> &[Diagnostic] {
1360 &self.core.pending_diagnostics
1361 }
1362
1363 pub fn into_inner(self) -> R {
1365 self.inner
1366 }
1367
1368 fn advance(&mut self) -> Result<bool, SynxlStreamError> {
1370 if let Some((text, no, dropped)) = self.lookahead.take() {
1371 self.cur = text;
1372 self.cur_no = no;
1373 self.cur_dropped = dropped;
1374 return Ok(true);
1375 }
1376 let dropped =
1377 match read_capped_line(&mut self.inner, &mut self.scratch, MAX_SYNXL_RECORD_BYTES)? {
1378 Some(d) => d,
1379 None => return Ok(false),
1380 };
1381 if dropped > 0 {
1384 for _ in 0..3 {
1385 if std::str::from_utf8(&self.scratch).is_ok() {
1386 break;
1387 }
1388 self.scratch.pop();
1389 }
1390 }
1391 let text = std::str::from_utf8(&self.scratch).map_err(|e| {
1392 std::io::Error::new(
1393 std::io::ErrorKind::InvalidData,
1394 format!("line {} is not valid UTF-8 (§3.1): {}", self.line_no + 1, e),
1395 )
1396 })?;
1397 self.cur.clear();
1398 self.cur.push_str(if self.line_no == 0 {
1400 text.strip_prefix('\u{feff}').unwrap_or(text)
1401 } else {
1402 text
1403 });
1404 self.line_no += 1;
1405 self.cur_no = self.line_no;
1406 self.cur_dropped = dropped;
1407 Ok(true)
1408 }
1409
1410 fn push_back(&mut self) {
1412 self.lookahead = Some((std::mem::take(&mut self.cur), self.cur_no, self.cur_dropped));
1413 }
1414
1415 fn read_prologue(&mut self) -> Result<(), SynxlStreamError> {
1417 loop {
1418 if !self.advance()? {
1419 return Err(ReaderCore::missing_prologue(self.line_no).into());
1420 }
1421 let trimmed = self.cur.trim();
1422 if self.core.prologue_skip(trimmed) {
1423 continue;
1424 }
1425 self.core.version = parse_prologue(trimmed, self.cur_no)?;
1426 return Ok(());
1427 }
1428 }
1429
1430 fn next_record(&mut self) -> Result<Option<SynxlRecord>, SynxlStreamError> {
1432 loop {
1433 if !self.advance()? {
1434 return Ok(None);
1435 }
1436 let trimmed = self.cur.trim();
1437 if trimmed.is_empty() {
1438 continue;
1439 }
1440 if self.core.in_block_comment {
1443 if trimmed == "###" {
1444 self.core.in_block_comment = false;
1445 }
1446 continue;
1447 }
1448 if self.cur.len() - self.cur.trim_start().len() > 0 {
1450 let no = self.cur_no;
1451 self.core.note_orphan(trimmed, no);
1452 continue;
1453 }
1454 if let LineKind::Skip = self.core.classify(trimmed, self.cur_no)? {
1455 continue;
1456 }
1457
1458 let record_no = self.cur_no;
1459 let fl_idx = self.core.require_field_list(record_no)?;
1460 self.record.clear();
1461 self.record.push_str(&self.cur);
1462 let mut total = self.record.len() + self.cur_dropped;
1465 self.block.clear();
1466 self.deferred.clear();
1467 let mut block_first_no = 0usize;
1468
1469 loop {
1471 if !self.advance()? {
1472 break;
1473 }
1474 if self.cur.trim().is_empty() {
1475 if !self.block.is_empty() {
1479 self.deferred.push('\n');
1480 self.deferred.push_str(&self.cur);
1481 }
1482 continue;
1483 }
1484 if self.cur.len() - self.cur.trim_start().len() == 0 {
1485 self.push_back();
1486 break;
1487 }
1488 if block_first_no == 0 {
1489 block_first_no = self.cur_no;
1490 }
1491 let sep = usize::from(!self.block.is_empty());
1492 total += self.deferred.len() + sep + self.cur.len() + self.cur_dropped;
1493 let room = (MAX_SYNXL_RECORD_BYTES + self.deferred.len())
1496 .saturating_sub(self.record.len() + self.block.len());
1497 if room > self.deferred.len() + sep {
1498 let budget = room - self.deferred.len() - sep;
1499 self.block.push_str(&self.deferred);
1500 if sep == 1 {
1501 self.block.push('\n');
1502 }
1503 let piece_len = truncate_utf8(&self.cur, budget).len();
1504 self.block.push_str(&self.cur[..piece_len]);
1505 }
1506 self.deferred.clear();
1507 }
1508
1509 let truncated_total = if total > MAX_SYNXL_RECORD_BYTES { Some(total) } else { None };
1510 let frame = RecordFrame {
1511 line: &self.record,
1512 line_no: record_no,
1513 block: &self.block,
1514 block_first_no,
1515 truncated_total,
1516 };
1517 return self.core.build(fl_idx, frame).map(Some).map_err(Into::into);
1518 }
1519 }
1520}
1521
1522impl<R: std::io::BufRead> Iterator for SynxlStreamReader<R> {
1523 type Item = Result<SynxlRecord, SynxlStreamError>;
1524
1525 fn next(&mut self) -> Option<Self::Item> {
1526 if self.done {
1527 return None;
1528 }
1529 match self.next_record() {
1530 Ok(Some(rec)) => Some(Ok(rec)),
1531 Ok(None) => {
1532 self.done = true;
1533 None
1534 }
1535 Err(err) => {
1536 self.done = true;
1539 Some(Err(err))
1540 }
1541 }
1542 }
1543}
1544
1545fn read_capped_line<R: std::io::BufRead>(
1551 reader: &mut R,
1552 out: &mut Vec<u8>,
1553 cap: usize,
1554) -> std::io::Result<Option<usize>> {
1555 out.clear();
1556 let mut dropped = 0usize;
1557 let mut saw_any = false;
1558 loop {
1559 let (consumed, done) = {
1560 let available = match reader.fill_buf() {
1561 Ok(b) => b,
1562 Err(ref e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
1563 Err(e) => return Err(e),
1564 };
1565 if available.is_empty() {
1566 break;
1567 }
1568 saw_any = true;
1569 match memchr(b'\n', available) {
1570 Some(i) => {
1571 push_capped(out, &available[..i], cap, &mut dropped);
1572 (i + 1, true)
1573 }
1574 None => {
1575 let len = available.len();
1576 push_capped(out, available, cap, &mut dropped);
1577 (len, false)
1578 }
1579 }
1580 };
1581 reader.consume(consumed);
1582 if done {
1583 break;
1584 }
1585 }
1586 if !saw_any {
1587 return Ok(None);
1588 }
1589 if out.last() == Some(&b'\r') {
1590 out.pop();
1591 }
1592 Ok(Some(dropped))
1593}
1594
1595fn push_capped(out: &mut Vec<u8>, chunk: &[u8], cap: usize, dropped: &mut usize) {
1596 let room = cap.saturating_sub(out.len());
1597 let take = room.min(chunk.len());
1598 out.extend_from_slice(&chunk[..take]);
1599 *dropped += chunk.len() - take;
1600}
1601
1602pub fn parse_lines(text: &str) -> Result<SynxlDocument, SynxlError> {
1609 parse_lines_with(text, &SynxlOptions::default())
1610}
1611
1612pub fn parse_lines_with(text: &str, opts: &SynxlOptions) -> Result<SynxlDocument, SynxlError> {
1614 let mut reader = SynxlReader::with_options(text, opts.clone())?;
1615 let mut records = Vec::new();
1616 let mut record_field_lists = Vec::new();
1617 let mut record_lines = Vec::new();
1618 let mut diagnostics = Vec::new();
1619
1620 loop {
1621 let item = match reader.next() {
1622 Some(item) => item,
1623 None => break,
1624 };
1625 let mut rec = item?;
1626 if records.len() >= MAX_SYNXL_RECORDS {
1628 return Err(SynxlError::new(
1629 SynxlErrorKind::LimitExceeded,
1630 rec.line,
1631 format!("more than {} records in an in-memory parse", MAX_SYNXL_RECORDS),
1632 ));
1633 }
1634 records.push(rec.value);
1635 record_field_lists.push(rec.field_list);
1636 record_lines.push(rec.line);
1637 diagnostics.append(&mut rec.diagnostics);
1638 }
1639
1640 diagnostics.extend_from_slice(reader.trailing_diagnostics());
1643
1644 let version = reader.version();
1645 Ok(SynxlDocument {
1646 version,
1647 records,
1648 field_lists: reader.into_field_lists(),
1649 record_field_lists,
1650 record_lines,
1651 diagnostics,
1652 })
1653}
1654
1655fn block_key_lines(block: &str, first_line_no: usize) -> HashMap<&str, usize> {
1663 let mut running_min = usize::MAX;
1670 let mut map: HashMap<&str, usize> = HashMap::new();
1671 for (i, line) in block.split('\n').enumerate() {
1672 let line = line.strip_suffix('\r').unwrap_or(line);
1673 let trimmed = line.trim();
1674 if trimmed.is_empty() {
1675 continue;
1676 }
1677 let indent = line.len() - line.trim_start().len();
1678 if indent > running_min {
1679 continue;
1680 }
1681 running_min = indent;
1682 if trimmed.starts_with("- ") || trimmed.starts_with('!') {
1685 continue;
1686 }
1687 match trimmed.as_bytes()[0] {
1688 b'[' | b':' | b'-' | b'#' | b'/' | b'(' => continue,
1689 _ => {}
1690 }
1691 let key_end = trimmed
1692 .find(|c: char| c == ' ' || c == '\t' || c == '[' || c == ':' || c == '(')
1693 .unwrap_or(trimmed.len());
1694 map.entry(&trimmed[..key_end]).or_insert(first_line_no + i);
1695 }
1696 map
1697}
1698
1699fn parse_prologue(trimmed: &str, line: usize) -> Result<u32, SynxlError> {
1702 let rest = match trimmed.strip_prefix("!synxl") {
1703 Some(r) if starts_with_wsp(r) => r,
1704 _ => {
1705 return Err(SynxlError::new(
1706 SynxlErrorKind::MissingPrologue,
1707 line,
1708 format!("expected `!synxl <version>`, found `{}`", elide(trimmed)),
1709 ))
1710 }
1711 };
1712 let version = rest.trim();
1713 if version.is_empty() || !version.bytes().all(|b| b.is_ascii_digit()) {
1715 return Err(SynxlError::new(
1716 SynxlErrorKind::MissingPrologue,
1717 line,
1718 format!("prologue version must be a decimal integer, found `{}`", elide(version)),
1719 ));
1720 }
1721 match version.parse::<u32>() {
1722 Ok(v) if v == SYNXL_VERSION => Ok(v),
1723 _ => Err(SynxlError::new(
1724 SynxlErrorKind::UnsupportedVersion,
1725 line,
1726 format!("SYNXL version `{}` is not supported (this build implements {})", version, SYNXL_VERSION),
1727 )),
1728 }
1729}
1730
1731fn parse_field_list(rest: &str, source: &str, line: usize) -> Result<FieldList, SynxlError> {
1738 if rest.is_empty() {
1739 return Err(SynxlError::new(
1740 SynxlErrorKind::MalformedFieldList,
1741 line,
1742 "`!fields` declares no fields",
1743 ));
1744 }
1745
1746 let mut fields: Vec<FieldDecl> = Vec::new();
1747 for decl in rest.split(';') {
1748 let decl = decl.trim();
1749 if decl.is_empty() {
1750 return Err(SynxlError::new(
1751 SynxlErrorKind::MalformedFieldList,
1752 line,
1753 "empty field declaration",
1754 ));
1755 }
1756 if fields.len() >= MAX_SYNXL_FIELDS {
1757 return Err(SynxlError::new(
1758 SynxlErrorKind::LimitExceeded,
1759 line,
1760 format!("more than {} fields in one field list", MAX_SYNXL_FIELDS),
1761 ));
1762 }
1763 let field = parse_field_decl(decl, line)?;
1764 if fields.iter().any(|f| f.name == field.name) {
1766 return Err(SynxlError::new(
1767 SynxlErrorKind::DuplicateField,
1768 line,
1769 format!("duplicate field name `{}`", field.name),
1770 ));
1771 }
1772 fields.push(field);
1773 }
1774
1775 let list = FieldList::new(fields);
1776 if list.arity() == 0 {
1780 return Err(SynxlError::new(
1781 SynxlErrorKind::ZeroArity,
1782 line,
1783 "field list has arity 0 — every field is declared [block]; at least one inline field is required",
1784 ));
1785 }
1786 let mut list = list;
1787 list.line = line;
1788 list.source = source.to_string();
1789 Ok(list)
1790}
1791
1792fn parse_field_decl(decl: &str, line: usize) -> Result<FieldDecl, SynxlError> {
1794 let bytes = decl.as_bytes();
1795 let len = bytes.len();
1796
1797 let mut pos = 0usize;
1799 while pos < len {
1800 let ch = bytes[pos];
1801 if ch == b' ' || ch == b'\t' || ch == b'[' || ch == b'(' || ch == b':' {
1802 break;
1803 }
1804 pos += 1;
1805 }
1806 let name = &decl[..pos];
1807 if name.is_empty() {
1808 return Err(SynxlError::new(
1809 SynxlErrorKind::MalformedFieldList,
1810 line,
1811 format!("field declaration `{}` has no name", elide(decl)),
1812 ));
1813 }
1814 if name.len() > MAX_SYNXL_FIELD_NAME_BYTES {
1815 return Err(SynxlError::new(
1816 SynxlErrorKind::LimitExceeded,
1817 line,
1818 format!(
1819 "field name is {} bytes, limit is {}",
1820 name.len(),
1821 MAX_SYNXL_FIELD_NAME_BYTES
1822 ),
1823 ));
1824 }
1825
1826 let mut type_hint = None;
1828 if pos < len && bytes[pos] == b'(' {
1829 let start = pos + 1;
1830 match decl[start..].find(')') {
1831 Some(rel) => {
1832 type_hint = Some(decl[start..start + rel].trim().to_string());
1833 pos = start + rel + 1;
1834 }
1835 None => {
1836 return Err(SynxlError::new(
1837 SynxlErrorKind::MalformedFieldList,
1838 line,
1839 format!("unterminated `(` in field declaration `{}`", elide(decl)),
1840 ))
1841 }
1842 }
1843 }
1844
1845 let mut constraints = Constraints::default();
1847 let mut block = false;
1848 if pos < len && bytes[pos] == b'[' {
1849 let cstart = pos + 1;
1850 let mut depth = 1usize;
1851 let mut scan = cstart;
1852 while scan < len {
1853 match bytes[scan] {
1854 b'[' => depth += 1,
1855 b']' => {
1856 depth -= 1;
1857 if depth == 0 {
1858 break;
1859 }
1860 }
1861 _ => {}
1862 }
1863 scan += 1;
1864 }
1865 if depth != 0 {
1866 return Err(SynxlError::new(
1867 SynxlErrorKind::MalformedFieldList,
1868 line,
1869 format!("unterminated `[` in field declaration `{}`", elide(decl)),
1870 ));
1871 }
1872 let raw = &decl[cstart..scan];
1873 constraints = parser::parse_constraints(raw);
1875 block = raw
1878 .split(',')
1879 .map(|p| p.trim())
1880 .any(|p| p == "block");
1881 pos = scan + 1;
1882 }
1883
1884 let tail = decl[pos..].trim();
1886 if !tail.is_empty() {
1887 if tail.starts_with(':') {
1889 return Err(SynxlError::new(
1890 SynxlErrorKind::MarkerChain,
1891 line,
1892 format!("marker chain `{}` is not allowed in a field declaration", elide(tail)),
1893 ));
1894 }
1895 return Err(SynxlError::new(
1896 SynxlErrorKind::MalformedFieldList,
1897 line,
1898 format!("trailing `{}` in field declaration `{}`", elide(tail), elide(decl)),
1899 ));
1900 }
1901
1902 for hint in [type_hint.as_deref(), constraints.type_name.as_deref()]
1905 .into_iter()
1906 .flatten()
1907 {
1908 if is_non_deterministic_hint(hint) {
1909 return Err(SynxlError::new(
1910 SynxlErrorKind::NonDeterministicHint,
1911 line,
1912 format!("non-deterministic type hint `{}` on field `{}`", hint, name),
1913 ));
1914 }
1915 }
1916
1917 if block && (type_hint.is_some() || constraints.type_name.is_some()) {
1919 return Err(SynxlError::new(
1920 SynxlErrorKind::BlockWithType,
1921 line,
1922 format!("field `{}` combines `block` with a type", name),
1923 ));
1924 }
1925
1926 Ok(FieldDecl {
1927 name: name.to_string(),
1928 type_hint,
1929 constraints,
1930 block,
1931 })
1932}
1933
1934fn is_non_deterministic_hint(hint: &str) -> bool {
1935 matches!(hint, "random" | "random:int" | "random:float" | "random:bool")
1936}
1937
1938#[derive(Debug, Clone, Copy, PartialEq)]
1942struct Part<'a> {
1943 text: &'a str,
1944 quoted: bool,
1945}
1946
1947struct PartSplitter<'a> {
1953 s: &'a str,
1954 b: &'a [u8],
1955 pos: usize,
1956 finished: bool,
1957}
1958
1959impl<'a> PartSplitter<'a> {
1960 fn new(s: &'a str) -> Self {
1961 Self { s, b: s.as_bytes(), pos: 0, finished: false }
1962 }
1963}
1964
1965impl<'a> Iterator for PartSplitter<'a> {
1966 type Item = Part<'a>;
1967
1968 fn next(&mut self) -> Option<Part<'a>> {
1969 if self.finished {
1970 return None;
1971 }
1972 let len = self.b.len();
1973 let mut i = self.pos;
1974
1975 while i < len && (self.b[i] == b' ' || self.b[i] == b'\t') {
1977 i += 1;
1978 }
1979
1980 if i < len && (self.b[i] == b'"' || self.b[i] == b'\'') {
1982 let quote = self.b[i];
1983 if let Some(rel) = memchr(quote, &self.b[i + 1..]) {
1984 let close = i + 1 + rel;
1985 let mut j = close + 1;
1986 while j < len && (self.b[j] == b' ' || self.b[j] == b'\t') {
1987 j += 1;
1988 }
1989 if j >= len || self.b[j] == b';' {
1990 let part = Part { text: &self.s[i + 1..close], quoted: true };
1993 if j >= len {
1994 self.finished = true;
1995 self.pos = len;
1996 } else {
1997 self.pos = j + 1;
1998 }
1999 return Some(part);
2000 }
2001 }
2002 }
2003
2004 let end = match memchr(b';', &self.b[i..]) {
2007 Some(rel) => i + rel,
2008 None => len,
2009 };
2010 let text = trim_wsp(&self.s[i..end]);
2011 if end >= len {
2012 self.finished = true;
2013 self.pos = len;
2014 } else {
2015 self.pos = end + 1;
2016 }
2017 Some(Part { text, quoted: false })
2018 }
2019}
2020
2021fn cast_part(
2025 field: &FieldDecl,
2026 part: Part<'_>,
2027 record_index: usize,
2028 line: usize,
2029 diagnostics: &mut Vec<Diagnostic>,
2030) -> Value {
2031 if part.quoted {
2033 return Value::String(part.text.to_string());
2034 }
2035 if part.text.is_empty() {
2037 return Value::Null;
2038 }
2039 match field.type_name() {
2040 Some(hint) => match cast_typed_checked(part.text, hint) {
2042 Some(v) => v,
2043 None => {
2044 diagnostics.push(Diagnostic {
2045 record_index,
2046 line,
2047 kind: DiagnosticKind::CastFailed,
2048 message: format!(
2049 "field `{}`: `{}` is not a valid {}",
2050 field.name,
2051 elide(part.text),
2052 hint
2053 ),
2054 });
2055 Value::Null
2056 }
2057 },
2058 None => cast_inline(part.text),
2060 }
2061}
2062
2063fn cast_inline(raw: &str) -> Value {
2070 if is_quote_wrapped(raw) {
2071 return Value::String(raw.to_string());
2072 }
2073 parser::cast(raw)
2074}
2075
2076fn cast_typed_checked(raw: &str, hint: &str) -> Option<Value> {
2078 match hint {
2079 "int" => raw.parse::<i64>().ok().map(Value::Int),
2080 "float" => raw.parse::<f64>().ok().filter(|f| f.is_finite()).map(Value::Float),
2083 "bool" => match raw {
2084 "true" => Some(Value::Bool(true)),
2085 "false" => Some(Value::Bool(false)),
2086 _ => None,
2087 },
2088 "string" => Some(Value::String(raw.to_string())),
2089 _ => Some(cast_inline(raw)),
2092 }
2093}
2094
2095fn check_constraints(key: &str, value: &Value, c: &Constraints) -> Option<String> {
2099 if c.required {
2100 let empty = matches!(value, Value::Null)
2101 || matches!(value, Value::String(s) if s.is_empty());
2102 if empty {
2103 return Some(format!("`{}` is required", key));
2104 }
2105 }
2106 if matches!(value, Value::Null) {
2108 return None;
2109 }
2110
2111 if let Some(ref type_name) = c.type_name {
2112 let ok = match type_name.as_str() {
2113 "int" => matches!(value, Value::Int(_)),
2114 "float" => matches!(value, Value::Float(_) | Value::Int(_)),
2115 "bool" => matches!(value, Value::Bool(_)),
2116 "string" => matches!(value, Value::String(_)),
2117 _ => true,
2118 };
2119 if !ok {
2120 return Some(format!("`{}` expected type `{}`", key, type_name));
2121 }
2122 }
2123
2124 if let Some(ref enum_values) = c.enum_values {
2125 let as_str = match value {
2126 Value::String(s) => s.clone(),
2127 Value::Int(n) => n.to_string(),
2128 Value::Float(f) => f.to_string(),
2129 Value::Bool(b) => b.to_string(),
2130 _ => String::new(),
2131 };
2132 if !enum_values.contains(&as_str) {
2133 return Some(format!("`{}` must be one of [{}]", key, enum_values.join("|")));
2134 }
2135 }
2136
2137 let num = match value {
2139 Value::Int(n) => Some(*n as f64),
2140 Value::Float(f) => Some(*f),
2141 Value::String(s) if c.min.is_some() || c.max.is_some() => Some(s.len() as f64),
2142 _ => None,
2143 };
2144 if let Some(n) = num {
2145 if let Some(min) = c.min {
2146 if n < min {
2147 return Some(format!("`{}` value {} is below min {}", key, n, min));
2148 }
2149 }
2150 if let Some(max) = c.max {
2151 if n > max {
2152 return Some(format!("`{}` value {} exceeds max {}", key, n, max));
2153 }
2154 }
2155 }
2156
2157 if let Some(ref pattern) = c.pattern {
2158 if pattern.len() <= 256 {
2159 if let Value::String(ref s) = value {
2160 if let Ok(re) = regex::Regex::new(pattern) {
2162 if !re.is_match(s) {
2163 return Some(format!("`{}` does not match pattern /{}/", key, pattern));
2164 }
2165 }
2166 }
2167 }
2168 }
2169
2170 None
2171}
2172
2173pub fn write_document(doc: &SynxlDocument) -> Result<String, SynxlError> {
2183 let mut out = String::with_capacity(doc.records.len() * 64 + 64);
2184 out.push_str("!synxl 1\n");
2185
2186 if doc.records.is_empty() {
2187 if let Some(fl) = doc.field_lists.first() {
2189 write_group(&mut out, fl.fields(), &[])?;
2190 }
2191 return Ok(out);
2192 }
2193
2194 let mut start = 0usize;
2195 while start < doc.records.len() {
2196 let fl_idx = doc.record_field_lists.get(start).copied().unwrap_or(0);
2197 let mut end = start + 1;
2198 while end < doc.records.len()
2199 && doc.record_field_lists.get(end).copied().unwrap_or(0) == fl_idx
2200 {
2201 end += 1;
2202 }
2203 let empty = FieldList::new(Vec::new());
2204 let fl = doc.field_lists.get(fl_idx).unwrap_or(&empty);
2205 write_group(&mut out, fl.fields(), &doc.records[start..end])?;
2206 start = end;
2207 }
2208 Ok(out)
2209}
2210
2211pub fn write_lines(fields: &[FieldDecl], records: &[Value]) -> Result<String, SynxlError> {
2214 let mut out = String::with_capacity(records.len() * 64 + 64);
2215 out.push_str("!synxl 1\n");
2216 write_group(&mut out, fields, records)?;
2217 Ok(out)
2218}
2219
2220fn validate_writable_fields(fields: &[FieldDecl]) -> Result<(), SynxlError> {
2228 if fields.is_empty() {
2229 return Err(SynxlError::new(
2230 SynxlErrorKind::Unwritable,
2231 0,
2232 "a field list must declare at least one field (§5.3.4); an empty one reads back as a malformed field list",
2233 ));
2234 }
2235 if fields.len() > MAX_SYNXL_FIELDS {
2236 return Err(SynxlError::new(
2237 SynxlErrorKind::Unwritable,
2238 0,
2239 format!("field list declares {} fields, over the {} limit (§13)", fields.len(), MAX_SYNXL_FIELDS),
2240 ));
2241 }
2242 let mut seen: HashSet<&str> = HashSet::with_capacity(fields.len());
2243 for field in fields {
2244 let name = field.name.as_str();
2245 if name.is_empty() {
2246 return Err(SynxlError::new(
2247 SynxlErrorKind::Unwritable,
2248 0,
2249 "field name is empty (§5.1.1)",
2250 ));
2251 }
2252 if name.len() > MAX_SYNXL_FIELD_NAME_BYTES {
2253 return Err(SynxlError::new(
2254 SynxlErrorKind::Unwritable,
2255 0,
2256 format!(
2257 "field name `{}` is {} bytes, over the {} byte limit (§13)",
2258 elide(name),
2259 name.len(),
2260 MAX_SYNXL_FIELD_NAME_BYTES
2261 ),
2262 ));
2263 }
2264 if let Some(bad) = name
2265 .chars()
2266 .find(|c| matches!(c, ';' | '[' | '(' | ':') || c.is_whitespace())
2267 {
2268 return Err(SynxlError::new(
2269 SynxlErrorKind::Unwritable,
2270 0,
2271 format!(
2272 "field name `{}` contains `{}`, which §5.1.1 excludes; it would read back as a different schema",
2273 elide(name),
2274 bad.escape_debug()
2275 ),
2276 ));
2277 }
2278 if !seen.insert(name) {
2279 return Err(SynxlError::new(
2280 SynxlErrorKind::Unwritable,
2281 0,
2282 format!("field name `{}` is declared twice (§5.1.4)", elide(name)),
2283 ));
2284 }
2285 }
2286 Ok(())
2287}
2288fn write_group(
2289 out: &mut String,
2290 fields: &[FieldDecl],
2291 records: &[Value],
2292) -> Result<(), SynxlError> {
2293 validate_writable_fields(fields)?;
2294
2295 let mut block: Vec<bool> = fields
2298 .iter()
2299 .map(|f| {
2300 f.block
2301 || records.iter().any(|r| {
2302 r.as_object()
2303 .and_then(|m| m.get(&f.name))
2304 .map(|v| needs_block(v, f.type_name()))
2305 .unwrap_or(false)
2306 })
2307 })
2308 .collect();
2309
2310 if !fields.is_empty() && block.iter().all(|b| *b) {
2322 let candidate = (0..fields.len()).filter(|i| !fields[*i].block).find(|i| {
2323 !records.iter().any(|r| {
2324 matches!(
2325 r.as_object().and_then(|m| m.get(&fields[*i].name)),
2326 Some(Value::String(s))
2327 if s.contains('\n') || s.contains('\r') || s.contains(';')
2328 )
2329 })
2330 });
2331 match candidate {
2332 Some(i) => block[i] = false,
2333 None => {
2334 return Err(SynxlError::new(
2335 SynxlErrorKind::Unwritable,
2336 0,
2337 "every field would have to be promoted to a block, which leaves the field list at arity 0 (§5.3.4); the value has no SYNXL rendering",
2338 ))
2339 }
2340 }
2341 }
2342
2343 out.push_str("!fields ");
2344 for (i, field) in fields.iter().enumerate() {
2345 if i > 0 {
2346 out.push_str("; ");
2347 }
2348 write_field_decl(out, field, block[i]);
2349 }
2350 out.push('\n');
2351
2352 for record in records {
2353 let map = record.as_object();
2354 let all_null = fields.iter().enumerate().all(|(i, f)| {
2358 block[i]
2359 || map
2360 .and_then(|m| m.get(&f.name))
2361 .map(writes_as_empty)
2362 .unwrap_or(true)
2363 });
2364 if all_null {
2365 out.push(';');
2366 } else {
2367 let mut first = true;
2368 for (i, field) in fields.iter().enumerate() {
2369 if block[i] {
2370 continue;
2371 }
2372 if !first {
2373 out.push_str("; ");
2374 }
2375 first = false;
2376 let value = map.and_then(|m| m.get(&field.name)).unwrap_or(&Value::Null);
2377 write_inline(out, value, field.type_name());
2378 }
2379 }
2380 out.push('\n');
2381
2382 for (i, field) in fields.iter().enumerate() {
2383 if !block[i] {
2384 continue;
2385 }
2386 let value = match map.and_then(|m| m.get(&field.name)) {
2387 Some(v) if !v.is_null() => v,
2388 _ => continue,
2390 };
2391 write_synx_entry(out, &field.name, value, 2, 0);
2392 }
2393 }
2394 Ok(())
2395}
2396
2397fn write_field_decl(out: &mut String, field: &FieldDecl, block: bool) {
2398 out.push_str(&field.name);
2399 if !block {
2402 if let Some(ref hint) = field.type_hint {
2403 out.push('(');
2404 out.push_str(hint);
2405 out.push(')');
2406 }
2407 }
2408
2409 let c = &field.constraints;
2410 let mut parts: Vec<String> = Vec::new();
2411 if !block {
2412 if let Some(ref t) = c.type_name {
2413 parts.push(format!("type:{}", t));
2414 }
2415 }
2416 if c.required {
2417 parts.push("required".to_string());
2418 }
2419 if c.readonly {
2420 parts.push("readonly".to_string());
2421 }
2422 if let Some(min) = c.min {
2423 parts.push(format!("min:{}", trim_float(min)));
2424 }
2425 if let Some(max) = c.max {
2426 parts.push(format!("max:{}", trim_float(max)));
2427 }
2428 if let Some(ref p) = c.pattern {
2429 parts.push(format!("pattern:{}", p));
2430 }
2431 if let Some(ref e) = c.enum_values {
2432 parts.push(format!("enum:{}", e.join("|")));
2433 }
2434 if block {
2435 parts.push("block".to_string());
2436 }
2437 if !parts.is_empty() {
2438 out.push('[');
2439 out.push_str(&parts.join(", "));
2440 out.push(']');
2441 }
2442}
2443
2444fn writes_as_empty(value: &Value) -> bool {
2452 match value {
2453 Value::Null => true,
2454 Value::Float(f) => !f.is_finite(),
2455 _ => false,
2456 }
2457}
2458
2459fn needs_block(value: &Value, hint: Option<&str>) -> bool {
2461 match value {
2462 Value::Object(_) | Value::Array(_) => true,
2464 Value::String(s) | Value::Secret(s) => {
2465 s.contains('\n')
2466 || s.contains('\r')
2467 || (inline_needs_quote(s, hint) && pick_quote(s).is_none())
2471 }
2472 _ => false,
2473 }
2474}
2475
2476fn inline_needs_quote(s: &str, hint: Option<&str>) -> bool {
2481 s.is_empty()
2482 || s.starts_with(char::is_whitespace)
2486 || s.ends_with(char::is_whitespace)
2487 || s.contains(';')
2488 || s.starts_with('#')
2489 || s.starts_with("//")
2490 || s.starts_with('!')
2491 || s.starts_with('"')
2493 || s.starts_with('\'')
2494 || match hint {
2495 Some(h) => cast_typed_checked(s, h) != Some(Value::String(s.to_string())),
2500 None => cast_inline(s) != Value::String(s.to_string()),
2501 }
2502}
2503
2504fn pick_quote(s: &str) -> Option<char> {
2506 if !s.contains('"') {
2507 Some('"')
2508 } else if !s.contains('\'') {
2509 Some('\'')
2510 } else {
2511 None
2512 }
2513}
2514
2515fn write_inline(out: &mut String, value: &Value, hint: Option<&str>) {
2517 match value {
2518 Value::Null => {}
2520 Value::Bool(b) => out.push_str(if *b { "true" } else { "false" }),
2521 Value::Int(n) => {
2522 let mut buf = itoa::Buffer::new();
2523 out.push_str(buf.format(*n));
2524 }
2525 Value::Float(f) => write_float(out, *f),
2526 Value::String(s) | Value::Secret(s) => {
2527 if inline_needs_quote(s, hint) {
2528 match pick_quote(s) {
2529 Some(q) => {
2530 out.push(q);
2531 out.push_str(s);
2532 out.push(q);
2533 }
2534 None => out.push_str(s),
2537 }
2538 } else {
2539 out.push_str(s);
2540 }
2541 }
2542 Value::Object(_) | Value::Array(_) => {}
2544 }
2545}
2546
2547fn write_float(out: &mut String, f: f64) {
2554 if !f.is_finite() {
2555 return;
2556 }
2557 let s = f.to_string();
2558 if !s.contains('e') && !s.contains('E') {
2559 out.push_str(&s);
2560 if !s.contains('.') {
2561 out.push_str(".0");
2562 }
2563 return;
2564 }
2565 let expanded = format!("{:.*}", FLOAT_EXPANSION_PRECISION, f);
2568 let trimmed = expanded.trim_end_matches('0');
2569 let trimmed = if trimmed.ends_with('.') { &expanded[..trimmed.len() + 1] } else { trimmed };
2570 out.push_str(trimmed);
2571}
2572
2573fn trim_float(f: f64) -> String {
2574 let s = f.to_string();
2575 s.strip_suffix(".0").map(|s| s.to_string()).unwrap_or(s)
2576}
2577
2578fn write_synx_entry(out: &mut String, key: &str, value: &Value, indent: usize, depth: usize) {
2582 if depth > MAX_WRITE_DEPTH {
2583 return;
2584 }
2585 match value {
2586 Value::Object(map) => {
2587 push_indent(out, indent);
2588 out.push_str(key);
2589 out.push('\n');
2590 let mut keys: Vec<&String> = map.keys().collect();
2591 keys.sort_unstable();
2592 for k in keys {
2593 write_synx_entry(out, k, &map[k], indent + 2, depth + 1);
2594 }
2595 }
2596 Value::Array(items) => {
2597 push_indent(out, indent);
2598 out.push_str(key);
2599 if items.is_empty() {
2600 out.push_str(":join");
2603 }
2604 out.push('\n');
2605 for item in items {
2606 write_synx_item(out, item, indent + 2, depth + 1);
2607 }
2608 }
2609 Value::String(s) if s.contains('\n') => write_synx_multiline(out, key, s, indent),
2610 _ => {
2611 let scalar = synx_scalar(value);
2612 match scalar {
2613 Some(text) => {
2614 push_indent(out, indent);
2615 out.push_str(key);
2616 out.push(' ');
2617 out.push_str(&text);
2618 out.push('\n');
2619 }
2620 None => {
2624 let s = value.as_str().unwrap_or_default().to_string();
2625 write_synx_multiline(out, key, &s, indent);
2626 }
2627 }
2628 }
2629 }
2630}
2631
2632fn write_synx_item(out: &mut String, item: &Value, indent: usize, depth: usize) {
2634 if depth > MAX_WRITE_DEPTH {
2635 return;
2636 }
2637 match item {
2638 Value::Object(map) => {
2639 let mut keys: Vec<&String> = map.keys().collect();
2640 keys.sort_unstable();
2641 let head = keys
2644 .iter()
2645 .position(|k| dash_line_safe(&map[*k]))
2646 .unwrap_or(0);
2647 let Some(head_key) = keys.get(head).copied() else {
2651 push_indent(out, indent);
2652 out.push_str("-\n");
2653 return;
2654 };
2655 push_indent(out, indent);
2656 out.push_str("- ");
2657 out.push_str(head_key);
2658 match synx_scalar(&map[head_key]) {
2659 Some(text) if !text.is_empty() => {
2660 out.push(' ');
2661 out.push_str(&text);
2662 }
2663 _ => {}
2666 }
2667 out.push('\n');
2668 for (i, k) in keys.iter().enumerate() {
2669 if i == head {
2670 continue;
2671 }
2672 write_synx_entry(out, k, &map[*k], indent + 2, depth + 1);
2673 }
2674 }
2675 _ => {
2676 push_indent(out, indent);
2677 out.push_str("- ");
2678 match synx_scalar(item) {
2679 Some(text) => out.push_str(&text),
2680 None => out.push_str(item.as_str().unwrap_or_default()),
2681 }
2682 out.push('\n');
2683 }
2684 }
2685}
2686
2687fn dash_line_safe(value: &Value) -> bool {
2689 match value {
2690 Value::Object(map) => map.is_empty(),
2691 Value::Array(_) => false,
2692 Value::String(s) => !s.contains('\n') && synx_scalar(value).is_some() && !s.is_empty(),
2693 _ => true,
2694 }
2695}
2696
2697fn write_synx_multiline(out: &mut String, key: &str, body: &str, indent: usize) {
2699 push_indent(out, indent);
2700 out.push_str(key);
2701 out.push_str(" |+\n");
2702 for line in body.split('\n') {
2703 push_indent(out, indent + 2);
2704 out.push_str(line);
2705 out.push('\n');
2706 }
2707}
2708
2709fn synx_scalar(value: &Value) -> Option<String> {
2714 match value {
2715 Value::Null => Some("null".to_string()),
2716 Value::Bool(b) => Some(if *b { "true" } else { "false" }.to_string()),
2717 Value::Int(n) => Some(n.to_string()),
2718 Value::Float(f) => {
2719 if !f.is_finite() {
2722 return Some("null".to_string());
2723 }
2724 let mut s = String::new();
2725 write_float(&mut s, *f);
2726 Some(s)
2727 }
2728 Value::String(s) | Value::Secret(s) => {
2729 if s.contains('\n') || s.contains(" #") || s.contains(" //") {
2730 return None;
2731 }
2732 if !synx_value_needs_quote(s) {
2733 return Some(s.clone());
2734 }
2735 pick_quote(s).map(|q| format!("{}{}{}", q, s, q))
2736 }
2737 Value::Object(_) | Value::Array(_) => None,
2738 }
2739}
2740
2741fn synx_value_needs_quote(s: &str) -> bool {
2743 s.is_empty()
2744 || s != s.trim()
2745 || s == "|"
2746 || s == "|+"
2747 || is_quote_wrapped(s)
2748 || !matches!(parser::cast(s), Value::String(ref v) if v == s)
2749}
2750
2751#[inline]
2754fn starts_with_wsp(s: &str) -> bool {
2755 s.starts_with(' ') || s.starts_with('\t')
2756}
2757
2758#[inline]
2759fn trim_wsp(s: &str) -> &str {
2760 s.trim_matches(|c| c == ' ' || c == '\t')
2761}
2762
2763fn is_quote_wrapped(s: &str) -> bool {
2765 let b = s.as_bytes();
2766 b.len() >= 2
2767 && ((b[0] == b'"' && b[b.len() - 1] == b'"') || (b[0] == b'\'' && b[b.len() - 1] == b'\''))
2768}
2769
2770fn push_indent(out: &mut String, n: usize) {
2771 for _ in 0..n {
2772 out.push(' ');
2773 }
2774}
2775
2776fn truncate_utf8(s: &str, max: usize) -> &str {
2778 if s.len() <= max {
2779 return s;
2780 }
2781 let mut end = max;
2782 while end > 0 && !s.is_char_boundary(end) {
2783 end -= 1;
2784 }
2785 &s[..end]
2786}
2787
2788fn elide(s: &str) -> String {
2790 const MAX: usize = 48;
2791 if s.len() <= MAX {
2792 return s.to_string();
2793 }
2794 let cut = truncate_utf8(s, MAX);
2795 format!("{}…", cut)
2796}
2797
2798#[cfg(test)]
2799mod tests {
2800 use super::*;
2801
2802 fn doc(src: &str) -> SynxlDocument {
2803 parse_lines(src).expect("expected a successful parse")
2804 }
2805
2806 fn kinds(d: &SynxlDocument) -> Vec<DiagnosticKind> {
2807 d.diagnostics.iter().map(|x| x.kind).collect()
2808 }
2809
2810 #[test]
2813 fn parses_prologue_and_simple_records() {
2814 let d = doc("!synxl 1\n!fields id[type:int] ; name\n1 ; Wario\n2 ; Mario\n");
2815 assert_eq!(d.version, 1);
2816 assert_eq!(d.len(), 2);
2817 assert_eq!(d.to_json(), r#"[{"id":1,"name":"Wario"},{"id":2,"name":"Mario"}]"#);
2818 assert!(d.diagnostics.is_empty());
2819 }
2820
2821 #[test]
2822 fn bom_and_crlf_are_tolerated() {
2823 let d = doc("\u{feff}!synxl 1\r\n!fields a ; b\r\n1 ; 2\r\n");
2824 assert_eq!(d.to_json(), r#"[{"a":1,"b":2}]"#);
2825 }
2826
2827 #[test]
2828 fn comments_and_blank_lines_before_prologue() {
2829 let d = doc("# hi\n\n// there\n###\n!synxl 999\n###\n!synxl 1\n!fields a\nx\n");
2830 assert_eq!(d.to_json(), r#"[{"a":"x"}]"#);
2831 }
2832
2833 #[test]
2834 fn missing_prologue_is_a_hard_error() {
2835 let e = parse_lines("!fields a\nx\n").unwrap_err();
2836 assert_eq!(e.kind, SynxlErrorKind::MissingPrologue);
2837 let e = parse_lines("").unwrap_err();
2838 assert_eq!(e.kind, SynxlErrorKind::MissingPrologue);
2839 let e = parse_lines("!synxl\n!fields a\n").unwrap_err();
2840 assert_eq!(e.kind, SynxlErrorKind::MissingPrologue);
2841 let e = parse_lines("!synxl 1 extra\n!fields a\n").unwrap_err();
2843 assert_eq!(e.kind, SynxlErrorKind::MissingPrologue);
2844 }
2845
2846 #[test]
2847 fn unsupported_version_is_a_hard_error() {
2848 let e = parse_lines("!synxl 2\n!fields a\nx\n").unwrap_err();
2849 assert_eq!(e.kind, SynxlErrorKind::UnsupportedVersion);
2850 let e = parse_lines("!synxl 99999999999999999999\n!fields a\n").unwrap_err();
2851 assert_eq!(e.kind, SynxlErrorKind::UnsupportedVersion);
2852 }
2853
2854 #[test]
2855 fn record_without_field_list_is_a_hard_error() {
2856 let e = parse_lines("!synxl 1\nrogue record\n").unwrap_err();
2857 assert_eq!(e.kind, SynxlErrorKind::NoFieldList);
2858 assert_eq!(e.line, 2);
2859 }
2860
2861 #[test]
2862 fn field_list_can_be_redeclared_mid_document() {
2863 let src = "!synxl 1\n\
2865 !fields id[type:int] ; score[type:float]\n\
2866 1 ; 0.91\n\
2867 # schema evolution\n\
2868 !fields id[type:int] ; score[type:float] ; lang\n\
2869 3 ; 0.55 ; ru\n";
2870 let d = doc(src);
2871 assert_eq!(
2872 d.to_json(),
2873 r#"[{"id":1,"score":0.91},{"id":3,"lang":"ru","score":0.55}]"#
2874 );
2875 assert_eq!(d.field_lists.len(), 2);
2876 assert_eq!(d.record_field_lists, vec![0, 1]);
2877 assert_eq!(d.field_list_for(1).unwrap().arity(), 3);
2878 }
2879
2880 #[test]
2883 fn duplicate_field_name_is_a_hard_error() {
2884 let e = parse_lines("!synxl 1\n!fields a ; b ; a\nx ; y ; z\n").unwrap_err();
2885 assert_eq!(e.kind, SynxlErrorKind::DuplicateField);
2886 }
2887
2888 #[test]
2889 fn marker_chain_is_a_hard_error() {
2890 let e = parse_lines("!synxl 1\n!fields port:env:default:3000\n1\n").unwrap_err();
2891 assert_eq!(e.kind, SynxlErrorKind::MarkerChain);
2892 let e = parse_lines("!synxl 1\n!fields id[required]:env\n1\n").unwrap_err();
2893 assert_eq!(e.kind, SynxlErrorKind::MarkerChain);
2894 let e = parse_lines("!synxl 1\n!fields id:custom\n1\n").unwrap_err();
2896 assert_eq!(e.kind, SynxlErrorKind::MarkerChain);
2897 }
2898
2899 #[test]
2900 fn non_deterministic_hint_is_a_hard_error() {
2901 for src in [
2902 "!synxl 1\n!fields id(random)\n1\n",
2903 "!synxl 1\n!fields id(random:float)\n1\n",
2904 "!synxl 1\n!fields id[type:random:bool]\n1\n",
2905 ] {
2906 let e = parse_lines(src).unwrap_err();
2907 assert_eq!(e.kind, SynxlErrorKind::NonDeterministicHint, "{src}");
2908 }
2909 }
2910
2911 #[test]
2912 fn block_with_type_is_a_hard_error() {
2913 let e = parse_lines("!synxl 1\n!fields m(int)[block]\n\n").unwrap_err();
2914 assert_eq!(e.kind, SynxlErrorKind::BlockWithType);
2915 let e = parse_lines("!synxl 1\n!fields m[block, type:int]\n\n").unwrap_err();
2916 assert_eq!(e.kind, SynxlErrorKind::BlockWithType);
2917 }
2918
2919 #[test]
2920 fn malformed_field_lists_are_hard_errors() {
2921 for src in [
2922 "!synxl 1\n!fields\nx\n",
2923 "!synxl 1\n!fields a ; ; b\nx\n",
2924 "!synxl 1\n!fields a[required\nx\n",
2925 "!synxl 1\n!fields a(int\nx\n",
2926 ] {
2927 let e = parse_lines(src).unwrap_err();
2928 assert_eq!(e.kind, SynxlErrorKind::MalformedFieldList, "{src}");
2929 }
2930 }
2931
2932 #[test]
2933 fn field_name_length_limit() {
2934 let long = "n".repeat(MAX_SYNXL_FIELD_NAME_BYTES + 1);
2935 let e = parse_lines(&format!("!synxl 1\n!fields {long}\nx\n")).unwrap_err();
2936 assert_eq!(e.kind, SynxlErrorKind::LimitExceeded);
2937 }
2938
2939 #[test]
2940 fn field_count_limit() {
2941 let mut src = String::from("!synxl 1\n!fields ");
2942 for i in 0..(MAX_SYNXL_FIELDS + 1) {
2943 if i > 0 {
2944 src.push_str(" ; ");
2945 }
2946 src.push_str(&format!("f{i}"));
2947 }
2948 src.push('\n');
2949 let e = parse_lines(&src).unwrap_err();
2950 assert_eq!(e.kind, SynxlErrorKind::LimitExceeded);
2951 }
2952
2953 #[test]
2954 fn unrecognised_constraint_parts_are_tolerated() {
2955 let d = doc("!synxl 1\n!fields a[required, futureflag, future:thing]\n5\n");
2957 assert_eq!(d.to_json(), r#"[{"a":5}]"#);
2958 assert!(d.field_lists[0].get("a").unwrap().constraints.required);
2959 }
2960
2961 #[test]
2962 fn arity_ignores_block_fields() {
2963 let d = doc("!synxl 1\n!fields a ; b ; m[block]\n1 ; 2\n");
2964 assert_eq!(d.field_lists[0].arity(), 2);
2965 assert!(d.diagnostics.is_empty());
2966 assert_eq!(d.to_json(), r#"[{"a":1,"b":2,"m":null}]"#);
2967 }
2968
2969 #[test]
2972 fn synx_first_character_filter_does_not_apply() {
2973 let src = "!synxl 1\n!fields v\n-5\n/var/log/app\n@kaiserberg\n[unparsed]\n:marker\n(paren)\n";
2974 let d = doc(src);
2975 assert_eq!(
2976 d.to_json(),
2977 r#"[{"v":-5},{"v":"/var/log/app"},{"v":"@kaiserberg"},{"v":"[unparsed]"},{"v":":marker"},{"v":"(paren)"}]"#
2978 );
2979 }
2980
2981 #[test]
2982 fn reserved_prefixes_at_indent_zero() {
2983 let d = doc("!synxl 1\n!fields v\n//comment\n/data\n# comment\n\"!bang\"\n'#quoted'\n");
2986 assert_eq!(d.to_json(), r##"[{"v":"/data"},{"v":"!bang"},{"v":"#quoted"}]"##);
2987 }
2988
2989 #[test]
2990 fn unknown_bang_line_is_a_hard_error() {
2991 for src in [
2994 "!synxl 1\n!fields a\n1\n!filds a ; b\n2 ; 3\n",
2995 "!synxl 1\n!active\n!fields a\n1\n",
2996 "!synxl 1\n!fields a\n!include /etc/passwd\n1\n",
2997 "!synxl 1\n!fieldsx a\n1\n",
2998 ] {
2999 let e = parse_lines(src).unwrap_err();
3000 assert_eq!(e.kind, SynxlErrorKind::UnknownDirective, "{src}");
3001 }
3002 }
3003
3004 #[test]
3005 fn repeated_prologue() {
3006 let d = doc("!synxl 1\n!fields a\n1\n!synxl 1\n!fields a\n2\n");
3008 assert_eq!(d.to_json(), r#"[{"a":1},{"a":2}]"#);
3009 let e = parse_lines("!synxl 1\n!fields a\n1\n!synxl 2\n!fields a\n2\n").unwrap_err();
3010 assert_eq!(e.kind, SynxlErrorKind::UnsupportedVersion);
3011 assert_eq!(e.line, 4);
3012 }
3013
3014 #[test]
3015 fn zero_arity_field_list_is_a_hard_error() {
3016 let e = parse_lines("!synxl 1\n!fields m[block]\n\n m\n k v\n").unwrap_err();
3018 assert_eq!(e.kind, SynxlErrorKind::ZeroArity);
3019 let e = parse_lines("!synxl 1\n!fields a[block] ; b[block]\n").unwrap_err();
3020 assert_eq!(e.kind, SynxlErrorKind::ZeroArity);
3021 }
3022
3023 #[test]
3026 fn quoting_and_the_unquoted_fallback() {
3027 let d = doc(
3028 "!synxl 1\n!fields a ; b ; c\n\
3029 \"has ; semi\" ; 'single' ; \"a\"b\" \n",
3030 );
3031 let rec = d.records[0].as_object().unwrap();
3032 assert_eq!(rec["a"], Value::String("has ; semi".into()));
3033 assert_eq!(rec["b"], Value::String("single".into()));
3034 assert_eq!(rec["c"], Value::String("\"a\"b\"".into()));
3037 }
3038
3039 #[test]
3040 fn unterminated_quote_falls_back_to_unquoted() {
3041 let d = doc("!synxl 1\n!fields a ; b\n\"open ; still\n");
3042 let rec = d.records[0].as_object().unwrap();
3043 assert_eq!(rec["a"], Value::String("\"open".into()));
3044 assert_eq!(rec["b"], Value::String("still".into()));
3045 }
3046
3047 #[test]
3048 fn quoted_values_bypass_casting() {
3049 let d = doc("!synxl 1\n!fields a ; b ; c\n\"42\" ; \"true\" ; \"null\"\n");
3050 assert_eq!(d.to_json(), r#"[{"a":"42","b":"true","c":"null"}]"#);
3051 }
3052
3053 #[test]
3054 fn null_versus_empty_string() {
3055 let d = doc("!synxl 1\n!fields a ; b ; c\n; \"\" ; x\n");
3058 assert_eq!(d.to_json(), r#"[{"a":null,"b":"","c":"x"}]"#);
3059 }
3060
3061 #[test]
3062 fn quoted_part_keeps_interior_whitespace() {
3063 let d = doc("!synxl 1\n!fields a ; b\n\" padded \" ; bare \n");
3064 let rec = d.records[0].as_object().unwrap();
3065 assert_eq!(rec["a"], Value::String(" padded ".into()));
3066 assert_eq!(rec["b"], Value::String("bare".into()));
3067 }
3068
3069 #[test]
3070 fn only_spaces_and_tabs_are_trimmed_from_a_part() {
3071 let d = doc("!synxl 1\n!fields a ; b\nx ; \u{a0}hello\u{a0}\n");
3076 assert_eq!(
3077 d.records[0].as_object().unwrap()["b"],
3078 Value::String("\u{a0}hello\u{a0}".into())
3079 );
3080 let d = doc("!synxl 1\n!fields a\n\u{a0}hello\n");
3082 assert_eq!(d.to_json(), "[]");
3083 assert_eq!(kinds(&d), vec![DiagnosticKind::OrphanBlockLine]);
3084 }
3085
3086 #[test]
3087 fn arity_mismatch_in_both_directions() {
3088 let d = doc("!synxl 1\n!fields a ; b ; c\n1 ; 2\n1 ; 2 ; 3 ; 4 ; 5\n");
3089 assert_eq!(kinds(&d), vec![DiagnosticKind::MissingFields, DiagnosticKind::ExtraFields]);
3090 assert_eq!(d.diagnostics[0].record_index, 0);
3091 assert_eq!(d.diagnostics[0].line, 3);
3092 assert_eq!(d.diagnostics[1].record_index, 1);
3093 assert_eq!(d.diagnostics[1].line, 4);
3094 assert_eq!(d.to_json(), r#"[{"a":1,"b":2,"c":null},{"a":1,"b":2,"c":3}]"#);
3095 }
3096
3097 #[test]
3098 fn all_null_record() {
3099 let d = doc("!synxl 1\n!fields a\n;\n");
3101 assert_eq!(d.to_json(), r#"[{"a":null}]"#);
3102 assert!(d.diagnostics.is_empty());
3103
3104 let d = doc("!synxl 1\n!fields a ; b\n;\n");
3105 assert_eq!(d.to_json(), r#"[{"a":null,"b":null}]"#);
3106 assert!(d.diagnostics.is_empty());
3107
3108 let d = doc("!synxl 1\n!fields a ; b ; c\n;\t\n");
3110 assert_eq!(d.to_json(), r#"[{"a":null,"b":null,"c":null}]"#);
3111 assert!(d.diagnostics.is_empty());
3112
3113 let d = doc("!synxl 1\n!fields a ; m[block]\n;\n m\n k v\n");
3115 assert_eq!(d.to_json(), r#"[{"a":null,"m":{"k":"v"}}]"#);
3116 assert!(d.diagnostics.is_empty());
3117
3118 let d = doc("!synxl 1\n!fields a ; b\n;;\n");
3121 assert_eq!(d.to_json(), r#"[{"a":null,"b":null}]"#);
3122 assert_eq!(kinds(&d), vec![DiagnosticKind::ExtraFields]);
3123 }
3124
3125 #[test]
3126 fn all_null_record_round_trips_at_every_arity() {
3127 for fields in ["a", "a ; b", "a ; b ; c", "a ; b ; m[block]"] {
3130 let src = format!("!synxl 1\n!fields {fields}\n;\n");
3131 let a = doc(&src);
3132 let written = a.to_synxl().unwrap();
3133 assert!(
3134 written.lines().any(|l| l == ";"),
3135 "expected an all-null record line for `{fields}`:\n{written}"
3136 );
3137 assert_eq!(a.to_json(), doc(&written).to_json(), "{written}");
3138 assert!(doc(&written).diagnostics.is_empty(), "{written}");
3139 }
3140 }
3141
3142 #[test]
3143 fn trailing_semicolon_yields_a_part() {
3144 let d = doc("!synxl 1\n!fields a ; b\n1 ;\n");
3145 assert_eq!(d.to_json(), r#"[{"a":1,"b":null}]"#);
3146 assert!(d.diagnostics.is_empty());
3147 }
3148
3149 #[test]
3150 fn inline_comments_are_not_stripped() {
3151 let d = doc("!synxl 1\n!fields text\nsee https://x.dev #hashtag // not a comment\n");
3153 assert_eq!(
3154 d.records[0].as_object().unwrap()["text"],
3155 Value::String("see https://x.dev #hashtag // not a comment".into())
3156 );
3157 }
3158
3159 #[test]
3162 fn automatic_casting() {
3163 let d = doc("!synxl 1\n!fields a ; b ; c ; d ; e ; f\n1 ; -2.5 ; true ; false ; null ; text\n");
3164 assert_eq!(
3165 d.to_json(),
3166 r#"[{"a":1,"b":-2.5,"c":true,"d":false,"e":null,"f":"text"}]"#
3167 );
3168 }
3169
3170 #[test]
3171 fn typed_cast_failure_nulls_the_cell_only() {
3172 let d = doc("!synxl 1\n!fields id[type:int] ; score(float)\nnope ; 0.5\n7 ; nope\n");
3173 assert_eq!(kinds(&d), vec![DiagnosticKind::CastFailed, DiagnosticKind::CastFailed]);
3174 assert_eq!(d.to_json(), r#"[{"id":null,"score":0.5},{"id":7,"score":null}]"#);
3175 }
3176
3177 #[test]
3178 fn typed_bool_and_string() {
3179 let d = doc("!synxl 1\n!fields a[type:bool] ; b(string) ; c[type:bool]\ntrue ; 42 ; yes\n");
3180 assert_eq!(d.to_json(), r#"[{"a":true,"b":"42","c":null}]"#);
3181 assert_eq!(kinds(&d), vec![DiagnosticKind::CastFailed]);
3182 }
3183
3184 #[test]
3185 fn validation_is_opt_in() {
3186 let src = "!synxl 1\n!fields id[type:int, min:10] ; name[required]\n5 ; \n";
3187 let d = doc(src);
3188 assert!(d.diagnostics.is_empty());
3189
3190 let d = parse_lines_with(src, &SynxlOptions { validate: true }).unwrap();
3191 assert_eq!(
3192 kinds(&d),
3193 vec![DiagnosticKind::ConstraintViolation, DiagnosticKind::ConstraintViolation]
3194 );
3195 }
3196
3197 #[test]
3200 fn worked_example_from_the_spec() {
3201 let src = "!synxl 1
3204!fields id[type:int, required] ; score[type:float] ; messages[block]
3205
32061 ; 0.91
3207 messages
3208 - role system
3209 content You are a helpful assistant.
3210 - role user
3211 content |+
3212 def f(x):
3213 return x + 1
3214
32152 ; 0.74
3216 messages
3217 - role user
3218 content Привет
3219";
3220 let d = doc(src);
3221 assert_eq!(d.len(), 2);
3222 assert!(d.diagnostics.is_empty());
3223 let mut expected = String::from(
3224 r#"{"id":1,"messages":[{"content":"You are a helpful assistant.","role":"system"},"#,
3225 );
3226 expected.push_str(r#"{"content":"def f(x):\n return x + 1","role":"user"}],"score":0.91}"#);
3227 let mut json = String::new();
3228 crate::write_json(&mut json, &d.records[0]);
3229 assert_eq!(json, expected);
3230 assert_eq!(
3231 d.to_ndjson().lines().count(),
3232 2,
3233 "NDJSON projection is one object per line"
3234 );
3235 }
3236
3237 #[test]
3238 fn empty_block_yields_null_block_fields() {
3239 let d = doc("!synxl 1\n!fields a ; m[block]\n1\n\n2\n");
3240 assert_eq!(d.to_json(), r#"[{"a":1,"m":null},{"a":2,"m":null}]"#);
3241 }
3242
3243 #[test]
3244 fn blank_lines_do_not_terminate_a_block() {
3245 let d = doc("!synxl 1\n!fields a ; m[block]\n1\n\n m\n x 1\n\n2\n");
3246 assert_eq!(d.to_json(), r#"[{"a":1,"m":{"x":1}},{"a":2,"m":null}]"#);
3247 }
3248
3249 #[test]
3250 fn block_key_diagnostics() {
3251 let d = doc("!synxl 1\n!fields a ; m[block]\n1\n bogus 1\n a 99\n");
3252 assert_eq!(
3254 kinds(&d),
3255 vec![DiagnosticKind::BlockFieldNotDeclared, DiagnosticKind::UnknownBlockKey]
3256 );
3257 assert_eq!(d.diagnostics[0].line, 5);
3260 assert_eq!(d.diagnostics[1].line, 4);
3261 assert!(d.diagnostics.iter().all(|x| x.record_index == 0));
3262 assert_eq!(d.to_json(), r#"[{"a":1,"m":null}]"#);
3264 }
3265
3266 #[test]
3267 fn block_diagnostic_line_survives_nesting_and_multiline() {
3268 let src = "!synxl 1
3269!fields id ; m[block]
32701
3271 m
3272 - role user
3273 content |+
3274 text
3275 zzz here
3276";
3277 let d = doc(src);
3278 assert_eq!(kinds(&d), vec![DiagnosticKind::UnknownBlockKey]);
3279 assert_eq!(d.diagnostics[0].line, 8);
3281 }
3282
3283 #[test]
3284 fn type_hints_inside_a_cell_are_not_interpreted() {
3285 let d = doc("!synxl 1\n!fields a ; b\n(random) ; (int)5\n");
3287 assert_eq!(d.to_json(), r#"[{"a":"(random)","b":"(int)5"}]"#);
3288 }
3289
3290 #[test]
3291 fn directives_inside_a_block_are_ignored() {
3292 let src = "!synxl 1\n!fields a ; m[block]\n1\n m\n !include /etc/passwd\n k v\n";
3294 let d = doc(src);
3295 assert_eq!(d.to_json(), r#"[{"a":1,"m":{"k":"v"}}]"#);
3296 }
3297
3298 #[test]
3299 fn directives_inside_a_multiline_body_are_preserved() {
3300 let src = "!synxl 1\n!fields a ; m[block]\n1\n m |+\n !include /etc/passwd\n !active\n";
3302 let d = doc(src);
3303 assert_eq!(
3304 d.records[0].as_object().unwrap()["m"],
3305 Value::String("!include /etc/passwd\n!active".into())
3306 );
3307 }
3308
3309 #[test]
3310 fn active_mode_cannot_be_switched_on_from_a_block() {
3311 let src = "!synxl 1\n!fields a ; m[block]\n1\n !active\n m\n tax:calc 2 * 2\n";
3313 let d = doc(src);
3314 assert_eq!(d.to_json(), r#"[{"a":1,"m":{"tax":"2 * 2"}}]"#);
3315 }
3316
3317 #[test]
3318 fn orphan_block_lines_are_reported_against_the_next_record() {
3319 let d = doc("!synxl 1\n!fields a\n stray one\n5\n!fields a\n stray two\n");
3321 assert_eq!(d.to_json(), r#"[{"a":5}]"#);
3322 assert_eq!(
3323 kinds(&d),
3324 vec![DiagnosticKind::OrphanBlockLine, DiagnosticKind::OrphanBlockLine]
3325 );
3326 assert_eq!((d.diagnostics[0].record_index, d.diagnostics[0].line), (0, 3));
3329 assert_eq!((d.diagnostics[1].record_index, d.diagnostics[1].line), (1, 6));
3330 }
3331
3332 #[test]
3335 fn oversized_record_is_truncated_not_rejected() {
3336 let mut src = String::from("!synxl 1\n!fields a ; b\n");
3337 src.push_str("head ; ");
3338 src.push_str(&"x".repeat(MAX_SYNXL_RECORD_BYTES + 16));
3339 src.push_str("\ntail ; ok\n");
3340 let d = doc(&src);
3341 assert_eq!(d.len(), 2, "the following record still parses");
3342 assert_eq!(kinds(&d), vec![DiagnosticKind::RecordTruncated]);
3343 assert_eq!(d.diagnostics[0].record_index, 0);
3344 let b = d.records[0].as_object().unwrap()["b"].as_str().unwrap();
3345 assert!(b.len() < MAX_SYNXL_RECORD_BYTES);
3346 assert_eq!(d.records[1].as_object().unwrap()["a"], Value::String("tail".into()));
3347 }
3348
3349 #[test]
3350 fn field_list_count_limit() {
3351 let mut src = String::from("!synxl 1\n");
3352 for _ in 0..(MAX_SYNXL_FIELD_LISTS + 1) {
3353 src.push_str("!fields a\n");
3354 }
3355 let e = parse_lines(&src).unwrap_err();
3356 assert_eq!(e.kind, SynxlErrorKind::LimitExceeded);
3357 }
3358
3359 #[test]
3360 fn truncation_respects_utf8_boundaries() {
3361 let s = "aя";
3363 assert_eq!(truncate_utf8(s, 2), "a");
3364 assert_eq!(truncate_utf8(s, 3), "aя");
3365 assert_eq!(truncate_utf8(s, 99), "aя");
3366 assert_eq!(truncate_utf8("😀", 3), "");
3367 }
3368
3369 #[test]
3370 fn streaming_and_whole_document_agree() {
3371 let src = "!synxl 1
3372!fields id[type:int] ; note ; m[block]
33731 ; ok
3374 m
3375 k v
33762 ; oops ; surplus
3377!fields id[type:int] ; note
33783
3379";
3380 let whole = doc(src);
3381 let mut records = Vec::new();
3382 let mut diagnostics = Vec::new();
3383 for item in SynxlReader::new(src).unwrap() {
3384 let mut rec = item.unwrap();
3385 records.push(rec.value);
3386 diagnostics.append(&mut rec.diagnostics);
3387 }
3388 assert_eq!(whole.to_json(), records_to_json_array(&records));
3389 assert_eq!(whole.diagnostics, diagnostics);
3390 assert_eq!(
3391 kinds(&whole),
3392 vec![DiagnosticKind::ExtraFields, DiagnosticKind::MissingFields]
3393 );
3394 }
3395
3396 #[test]
3397 fn a_document_may_exceed_the_synx_input_cap() {
3398 let mut src = String::from("!synxl 1\n!fields a ; b\n");
3400 let row = "0123456789 ; abcdefghij\n";
3401 let rows = (17 * 1024 * 1024) / row.len() + 1;
3402 src.reserve(rows * row.len());
3403 for _ in 0..rows {
3404 src.push_str(row);
3405 }
3406 assert!(src.len() > 16 * 1024 * 1024);
3407 let d = doc(&src);
3408 assert_eq!(d.len(), rows);
3409 assert!(d.diagnostics.is_empty());
3410 }
3411
3412 #[test]
3415 fn streaming_reader_yields_records_incrementally() {
3416 let src = "!synxl 1\n!fields a ; m[block]\n1\n m\n k v\n2 ; ignored\n";
3417 let mut reader = SynxlReader::new(src).unwrap();
3418 let first = reader.next().unwrap().unwrap();
3419 assert_eq!(first.index, 0);
3420 assert_eq!(first.line, 3);
3421 assert!(first.diagnostics.is_empty());
3422 let second = reader.next().unwrap().unwrap();
3423 assert_eq!(second.index, 1);
3424 assert_eq!(second.line, 6);
3425 assert_eq!(second.diagnostics.len(), 1);
3426 assert_eq!(second.diagnostics[0].kind, DiagnosticKind::ExtraFields);
3427 assert!(reader.next().is_none());
3428 }
3429
3430 #[test]
3431 fn owned_reader_matches_the_borrowing_one() {
3432 let src = "\u{feff}!synxl 1\r\n!fields id[type:int] ; note ; m[block]\r\n1 ; ok\r\n m\r\n k v\r\n2 ; oops ; surplus\r\n bogus 1\r\n";
3433
3434 let borrowed: Vec<SynxlRecord> = SynxlReader::new(src)
3435 .unwrap()
3436 .map(Result::unwrap)
3437 .collect();
3438
3439 let mut owned = {
3442 let moved = String::from(src);
3443 SynxlReaderOwned::new(moved).unwrap()
3444 };
3445 assert_eq!(owned.version(), 1);
3446 let mut collected = Vec::new();
3447 while let Some(item) = owned.next() {
3448 collected.push(item.unwrap());
3449 }
3450
3451 assert_eq!(collected, borrowed);
3452 assert_eq!(collected.len(), 2);
3453 assert_eq!(collected[1].line, 6, "BOM and CRLF must not shift line numbers");
3454 assert_eq!(owned.field_lists().len(), 1);
3455 assert_eq!(owned.text(), src);
3456 }
3457
3458 #[test]
3459 fn owned_reader_is_returnable_from_a_function() {
3460 fn open(src: &str) -> SynxlReaderOwned {
3461 SynxlReaderOwned::with_options(src.to_string(), SynxlOptions { validate: true })
3462 .unwrap()
3463 }
3464 let mut reader = open("!synxl 1\n!fields a[required]\n;\n");
3465 let rec = reader.next().unwrap().unwrap();
3466 assert_eq!(rec.diagnostics.len(), 1);
3467 assert_eq!(rec.diagnostics[0].kind, DiagnosticKind::ConstraintViolation);
3468 assert!(reader.next().is_none());
3469 assert_eq!(reader.into_text().lines().count(), 3);
3470 }
3471
3472 #[test]
3473 fn owned_reader_reports_the_prologue_error_eagerly() {
3474 let err = SynxlReaderOwned::new("nope\n".to_string()).unwrap_err();
3475 assert_eq!(err.kind, SynxlErrorKind::MissingPrologue);
3476 }
3477
3478 fn all_three_agree(src: &str) -> Vec<SynxlRecord> {
3483 let borrowed: Vec<SynxlRecord> =
3484 SynxlReader::new(src).unwrap().map(Result::unwrap).collect();
3485 let owned: Vec<SynxlRecord> = SynxlReaderOwned::new(src.to_string())
3486 .unwrap()
3487 .map(Result::unwrap)
3488 .collect();
3489 let streamed: Vec<SynxlRecord> = SynxlStreamReader::new(std::io::Cursor::new(src))
3490 .unwrap()
3491 .map(Result::unwrap)
3492 .collect();
3493 assert_eq!(owned, borrowed, "owned reader diverged");
3494 assert_eq!(streamed, borrowed, "io reader diverged");
3495 borrowed
3496 }
3497
3498 #[test]
3499 fn io_streaming_matches_the_in_memory_readers() {
3500 let recs = all_three_agree(
3501 "\u{feff}!synxl 1\r
3502!fields id[type:int] ; note ; m[block]\r
3503\r
35041 ; ok\r
3505 m\r
3506 - role user\r
3507 content |+\r
3508 line one\r
3509\r
3510 line two\r
35112 ; oops ; surplus\r
3512 bogus 1\r
3513!fields id[type:int]\r
3514 orphan\r
3515;\r
3516",
3517 );
3518 assert_eq!(recs.len(), 3);
3519 assert_eq!(recs[0].line, 4, "BOM and CRLF must not shift line numbers");
3520 assert_eq!(recs[1].diagnostics.len(), 2);
3521 assert_eq!(recs[2].value.as_object().unwrap()["id"], Value::Null);
3522 }
3523
3524 #[test]
3525 fn io_streaming_agrees_on_blocks_comments_and_diagnostics() {
3526 all_three_agree(
3527 "!synxl 1
3528###
3529!fields hidden
3530###
3531!fields a ; b ; m[block]
3532# comment
35331 ; 2
3534 m
3535 k v
3536
3537 j w
3538//comment
35393 ; 4 ; 5
3540 m
3541 !include /etc/passwd
3542 q |+
3543 !active
3544;
3545",
3546 );
3547 }
3548
3549 #[test]
3550 fn io_streaming_bounds_memory_on_an_oversized_record() {
3551 let mut src = String::from("!synxl 1\n!fields a ; b\n");
3554 src.push_str("head ; ");
3555 src.push_str(&"x".repeat(MAX_SYNXL_RECORD_BYTES + 4096));
3556 src.push_str("\ntail ; ok\n");
3557
3558 let streamed: Vec<SynxlRecord> = SynxlStreamReader::new(std::io::Cursor::new(&src))
3559 .unwrap()
3560 .map(Result::unwrap)
3561 .collect();
3562 let borrowed: Vec<SynxlRecord> =
3563 SynxlReader::new(&src).unwrap().map(Result::unwrap).collect();
3564
3565 assert_eq!(streamed.len(), 2, "the record after the oversized one survives");
3566 assert_eq!(streamed[0].diagnostics[0].kind, DiagnosticKind::RecordTruncated);
3567 assert_eq!(
3568 streamed[0].value.as_object().unwrap()["b"],
3569 borrowed[0].value.as_object().unwrap()["b"],
3570 "both readers must cut at the same byte"
3571 );
3572 assert_eq!(streamed[1].value, borrowed[1].value);
3573 }
3574
3575 #[test]
3576 fn io_streaming_bounds_memory_on_an_oversized_block() {
3577 let mut src = String::from("!synxl 1\n!fields a ; m[block]\n1\n m |+\n");
3578 let line = format!(" {}\n", "y".repeat(4095));
3579 for _ in 0..((MAX_SYNXL_RECORD_BYTES / line.len()) + 2) {
3580 src.push_str(&line);
3581 }
3582 src.push_str("2\n");
3583
3584 let streamed: Vec<SynxlRecord> = SynxlStreamReader::new(std::io::Cursor::new(&src))
3585 .unwrap()
3586 .map(Result::unwrap)
3587 .collect();
3588 let borrowed: Vec<SynxlRecord> =
3589 SynxlReader::new(&src).unwrap().map(Result::unwrap).collect();
3590
3591 assert_eq!(streamed.len(), 2);
3592 assert_eq!(streamed[0].diagnostics[0].kind, DiagnosticKind::RecordTruncated);
3593 assert_eq!(streamed[0].value, borrowed[0].value);
3594 assert_eq!(streamed[1].value, borrowed[1].value);
3595 }
3596
3597 #[test]
3598 fn io_streaming_survives_a_line_without_a_newline() {
3599 let mut src = String::from("!synxl 1\n!fields a\n");
3602 src.push_str(&"z".repeat(MAX_SYNXL_RECORD_BYTES + 1024));
3603 let recs: Vec<SynxlRecord> = SynxlStreamReader::new(std::io::Cursor::new(&src))
3604 .unwrap()
3605 .map(Result::unwrap)
3606 .collect();
3607 assert_eq!(recs.len(), 1);
3608 assert_eq!(recs[0].diagnostics[0].kind, DiagnosticKind::RecordTruncated);
3609 let a = recs[0].value.as_object().unwrap()["a"].as_str().unwrap();
3610 assert_eq!(a.len(), MAX_SYNXL_RECORD_BYTES);
3611 }
3612
3613 #[test]
3614 fn io_streaming_separates_format_errors_from_io_errors() {
3615 let err = SynxlStreamReader::new(std::io::Cursor::new("nope\n")).unwrap_err();
3617 assert_eq!(err.as_format().unwrap().kind, SynxlErrorKind::MissingPrologue);
3618 assert!(err.as_io().is_none());
3619
3620 let mut reader =
3621 SynxlStreamReader::new(std::io::Cursor::new("!synxl 1\n!fields a\n1\n!filds a\n2\n"))
3622 .unwrap();
3623 assert!(reader.next().unwrap().is_ok());
3624 let err = reader.next().unwrap().unwrap_err();
3625 assert_eq!(err.as_format().unwrap().kind, SynxlErrorKind::UnknownDirective);
3626 assert!(reader.next().is_none(), "iteration stops after a hard error");
3627
3628 let bad = [b'!', b's', b'y', b'n', b'x', b'l', b' ', b'1', b'\n', 0xff, 0xfe, b'\n'];
3630 let mut reader = SynxlStreamReader::new(std::io::Cursor::new(&bad[..])).unwrap();
3631 let err = reader.next().unwrap().unwrap_err();
3632 assert!(err.as_format().is_none());
3633 assert_eq!(err.as_io().unwrap().kind(), std::io::ErrorKind::InvalidData);
3634 }
3635
3636 #[test]
3637 fn io_streaming_reports_a_failing_source() {
3638 #[derive(Debug)]
3639 struct Boom;
3640 impl std::io::Read for Boom {
3641 fn read(&mut self, _: &mut [u8]) -> std::io::Result<usize> {
3642 Err(std::io::Error::new(std::io::ErrorKind::BrokenPipe, "boom"))
3643 }
3644 }
3645 let err = SynxlStreamReader::new(std::io::BufReader::new(Boom)).unwrap_err();
3646 assert_eq!(err.as_io().unwrap().kind(), std::io::ErrorKind::BrokenPipe);
3647 }
3648
3649 #[test]
3652 fn field_list_keeps_its_source_line() {
3653 let d = doc("!synxl 1\n!fields id[type:int] ; name \n1 ; x\n!fields a\n2\n");
3655 assert_eq!(d.field_lists[0].source(), "!fields id[type:int] ; name");
3656 assert_eq!(d.field_lists[0].line, 2);
3657 assert_eq!(d.field_lists[1].source(), "!fields a");
3658 assert_eq!(d.field_lists[1].line, 4);
3659 assert_eq!(FieldList::new(vec![FieldDecl::new("a")]).source(), "");
3661
3662 let mut reader = SynxlReader::new("!synxl 1\n!fields a ; b\n1 ; 2\n").unwrap();
3665 reader.next();
3666 assert_eq!(reader.field_list().unwrap().source(), "!fields a ; b");
3667 }
3668
3669 #[test]
3670 fn streaming_reader_reports_a_mid_document_hard_error_once() {
3671 let src = "!synxl 1\n!fields a\n1\n!fields a ; a\n2\n";
3672 let mut reader = SynxlReader::new(src).unwrap();
3673 assert!(reader.next().unwrap().is_ok());
3674 let err = reader.next().unwrap().unwrap_err();
3675 assert_eq!(err.kind, SynxlErrorKind::DuplicateField);
3676 assert!(reader.next().is_none(), "iteration stops after a hard error");
3677 }
3678
3679 fn round_trip(src: &str) {
3682 let a = doc(src);
3683 let written = a.to_synxl().unwrap();
3684 let b = doc(&written);
3685 assert_eq!(
3686 a.to_json(),
3687 b.to_json(),
3688 "round-trip mismatch\n--- written ---\n{written}"
3689 );
3690 }
3691
3692 #[test]
3693 fn round_trip_scalars_and_quoting() {
3694 round_trip(
3695 "!synxl 1\n!fields a ; b ; c ; d ; e ; f ; g\n\
3696 1 ; \"42\" ; \"has ; semi\" ; ; \"\" ; -2.5 ; true\n",
3697 );
3698 }
3699
3700 #[test]
3701 fn round_trip_reserved_prefixes_and_padding() {
3702 round_trip(
3703 "!synxl 1\n!fields a ; b ; c ; d\n\
3704 \"#tag\" ; \"//path\" ; \"!bang\" ; \" padded \"\n",
3705 );
3706 }
3707
3708 #[test]
3709 fn round_trip_blocks_and_schema_evolution() {
3710 round_trip(
3711 "!synxl 1
3712!fields id[type:int, required] ; score[type:float] ; messages[block]
37131 ; 0.91
3714 messages
3715 - role system
3716 content You are a helpful assistant.
3717 - role user
3718 content |+
3719 def f(x):
3720 return x + 1
3721!fields id[type:int] ; lang ; messages[block]
37223 ; ru
3723 messages
3724 - role user
3725 content Как дела?
3726",
3727 );
3728 }
3729
3730 #[test]
3731 fn writer_promotes_multiline_values_to_a_block() {
3732 let fields = vec![FieldDecl::new("id"), FieldDecl::new("text")];
3734 let mut rec = HashMap::new();
3735 rec.insert("id".to_string(), Value::Int(1));
3736 rec.insert("text".to_string(), Value::String("line one\n line two".into()));
3737 let records = vec![Value::Object(rec)];
3738 let out = write_lines(&fields, &records).unwrap();
3739 assert!(out.contains("text[block]"), "{out}");
3740 assert!(out.contains("|+"), "{out}");
3741 let back = doc(&out);
3742 assert_eq!(back.to_json(), records_to_json_array(&records));
3743 }
3744
3745 #[test]
3746 fn writer_promotes_values_that_cannot_be_quoted() {
3747 let fields = vec![FieldDecl::new("id"), FieldDecl::new("a")];
3749 let mut rec = HashMap::new();
3750 rec.insert("id".to_string(), Value::Int(1));
3751 rec.insert("a".to_string(), Value::String("mix \" and ' and ; here".into()));
3752 let records = vec![Value::Object(rec)];
3753 let out = write_lines(&fields, &records).unwrap();
3754 assert!(out.contains("a[block]"), "{out}");
3755 let back = doc(&out);
3756 assert_eq!(back.to_json(), records_to_json_array(&records));
3757 }
3758
3759 #[test]
3760 fn writer_rejects_an_unwritable_value() {
3761 let fields = vec![FieldDecl::new("a")];
3767 let mut rec = HashMap::new();
3768 rec.insert("a".to_string(), Value::String("mix \" and ' and ; here".into()));
3769 let records = vec![Value::Object(rec)];
3770
3771 let err = write_lines(&fields, &records).unwrap_err();
3772 assert_eq!(err.kind, SynxlErrorKind::Unwritable);
3773
3774 let doc = SynxlDocument {
3776 version: 1,
3777 records,
3778 field_lists: vec![FieldList::new(fields)],
3779 record_field_lists: vec![0],
3780 record_lines: vec![3],
3781 diagnostics: Vec::new(),
3782 };
3783 assert_eq!(doc.to_synxl().unwrap_err().kind, SynxlErrorKind::Unwritable);
3784 }
3785
3786 #[test]
3787 fn writer_keeps_one_column_inline_instead_of_zero_arity() {
3788 let fields = vec![FieldDecl::new("a")];
3791 let mut rec = HashMap::new();
3792 rec.insert("a".to_string(), Value::String("'x\"y".into()));
3793 let records = vec![Value::Object(rec)];
3794 let out = write_lines(&fields, &records).unwrap();
3795 assert!(!out.contains("[block]"), "{out}");
3796 let back = doc(&out);
3797 assert_eq!(back.to_json(), records_to_json_array(&records));
3798 }
3799
3800 #[test]
3801 fn writer_output_is_a_valid_document_without_records() {
3802 let d = doc("!synxl 1\n!fields a ; b\n");
3803 assert_eq!(d.to_synxl().unwrap(), "!synxl 1\n!fields a; b\n");
3804 assert_eq!(doc(&d.to_synxl().unwrap()).to_json(), "[]");
3805 }
3806
3807 #[test]
3808 fn write_lines_emits_the_canonical_shape() {
3809 let d = doc("!synxl 1\n!fields id[type:int] ; name\n1 ; Wario\n");
3810 let out = d.to_synxl().unwrap();
3811 assert_eq!(out, "!synxl 1\n!fields id[type:int]; name\n1; Wario\n");
3812 }
3813
3814 #[test]
3815 fn float_round_trips_through_exponent_form() {
3816 let fields = vec![FieldDecl::new("a"), FieldDecl::new("b"), FieldDecl::new("c")];
3817 let mut rec = HashMap::new();
3818 rec.insert("a".to_string(), Value::Float(1e300));
3819 rec.insert("b".to_string(), Value::Float(5.0));
3820 rec.insert("c".to_string(), Value::Float(f64::NAN));
3821 let records = vec![Value::Object(rec)];
3822 let out = write_lines(&fields, &records).unwrap();
3823 let back = doc(&out);
3824 assert_eq!(back.to_json(), records_to_json_array(&records));
3825 }
3826
3827 #[test]
3830 fn ndjson_projection() {
3831 let d = doc("!synxl 1\n!fields a ; b\n1 ; x\n2 ; y\n");
3832 assert_eq!(d.to_ndjson(), "{\"a\":1,\"b\":\"x\"}\n{\"a\":2,\"b\":\"y\"}\n");
3833 }
3834
3835 #[test]
3836 fn keys_are_sorted_in_the_projection() {
3837 let d = doc("!synxl 1\n!fields z ; a ; m\n1 ; 2 ; 3\n");
3838 assert_eq!(d.to_json(), r#"[{"a":2,"m":3,"z":1}]"#);
3839 }
3840
3841 #[test]
3844 fn splitter_edge_cases() {
3845 let parts: Vec<Part> = PartSplitter::new("a;b").collect();
3846 assert_eq!(parts.len(), 3 - 1);
3847 let parts: Vec<Part> = PartSplitter::new("").collect();
3848 assert_eq!(parts, vec![Part { text: "", quoted: false }]);
3849 let parts: Vec<Part> = PartSplitter::new("'x' ; \"y\"").collect();
3850 assert_eq!(
3851 parts,
3852 vec![
3853 Part { text: "x", quoted: true },
3854 Part { text: "y", quoted: true }
3855 ]
3856 );
3857 let parts: Vec<Part> = PartSplitter::new("'x'y ; z").collect();
3859 assert_eq!(parts[0], Part { text: "'x'y", quoted: false });
3860 let parts: Vec<Part> = PartSplitter::new("\"a;b\"").collect();
3862 assert_eq!(parts, vec![Part { text: "a;b", quoted: true }]);
3863 }
3864}