1use std::collections::HashMap;
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 = 64;
55
56const FLOAT_EXPANSION_PRECISION: usize = 1_100;
60
61#[derive(Debug, Clone, PartialEq)]
65pub struct FieldDecl {
66 pub name: String,
68 pub type_hint: Option<String>,
71 pub constraints: Constraints,
74 pub block: bool,
76}
77
78impl FieldDecl {
79 pub fn new(name: impl Into<String>) -> Self {
81 Self {
82 name: name.into(),
83 type_hint: None,
84 constraints: Constraints::default(),
85 block: false,
86 }
87 }
88
89 pub fn new_block(name: impl Into<String>) -> Self {
91 Self { block: true, ..Self::new(name) }
92 }
93
94 pub fn type_name(&self) -> Option<&str> {
96 self.type_hint
97 .as_deref()
98 .or(self.constraints.type_name.as_deref())
99 }
100}
101
102#[derive(Debug, Clone, PartialEq)]
104pub struct FieldList {
105 fields: Vec<FieldDecl>,
106 arity: usize,
107 source: String,
108 pub line: usize,
110}
111
112impl FieldList {
113 pub fn new(fields: Vec<FieldDecl>) -> Self {
118 let arity = fields.iter().filter(|f| !f.block).count();
119 Self { fields, arity, source: String::new(), line: 0 }
120 }
121
122 pub fn source(&self) -> &str {
131 &self.source
132 }
133
134 pub fn fields(&self) -> &[FieldDecl] {
136 &self.fields
137 }
138
139 pub fn arity(&self) -> usize {
142 self.arity
143 }
144
145 pub fn get(&self, name: &str) -> Option<&FieldDecl> {
147 self.fields.iter().find(|f| f.name == name)
148 }
149}
150
151#[derive(Debug, Clone, Copy, PartialEq, Eq)]
156pub enum SynxlErrorKind {
157 MissingPrologue,
159 UnsupportedVersion,
163 UnknownDirective,
166 NoFieldList,
168 MalformedFieldList,
170 DuplicateField,
172 MarkerChain,
175 NonDeterministicHint,
177 BlockWithType,
179 ZeroArity,
181 Unwritable,
187 LimitExceeded,
189}
190
191impl SynxlErrorKind {
192 pub fn as_str(self) -> &'static str {
193 match self {
194 SynxlErrorKind::MissingPrologue => "MissingPrologue",
195 SynxlErrorKind::UnsupportedVersion => "UnsupportedVersion",
196 SynxlErrorKind::UnknownDirective => "UnknownDirective",
197 SynxlErrorKind::NoFieldList => "NoFieldList",
198 SynxlErrorKind::MalformedFieldList => "MalformedFieldList",
199 SynxlErrorKind::DuplicateField => "DuplicateField",
200 SynxlErrorKind::MarkerChain => "MarkerChain",
201 SynxlErrorKind::NonDeterministicHint => "NonDeterministicHint",
202 SynxlErrorKind::BlockWithType => "BlockWithType",
203 SynxlErrorKind::ZeroArity => "ZeroArity",
204 SynxlErrorKind::Unwritable => "Unwritable",
205 SynxlErrorKind::LimitExceeded => "LimitExceeded",
206 }
207 }
208}
209
210impl fmt::Display for SynxlErrorKind {
211 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
212 f.write_str(self.as_str())
213 }
214}
215
216#[derive(Debug, Clone, PartialEq, Eq)]
218pub struct SynxlError {
219 pub kind: SynxlErrorKind,
220 pub line: usize,
221 pub message: String,
222}
223
224impl SynxlError {
225 pub fn new(kind: SynxlErrorKind, line: usize, message: impl Into<String>) -> Self {
232 Self { kind, line, message: message.into() }
233 }
234}
235
236impl fmt::Display for SynxlError {
237 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
238 write!(f, "SYNXL {} at line {}: {}", self.kind, self.line, self.message)
239 }
240}
241
242impl std::error::Error for SynxlError {}
243
244#[derive(Debug, Clone, Copy, PartialEq, Eq)]
246pub enum DiagnosticKind {
247 MissingFields,
249 ExtraFields,
251 CastFailed,
253 UnknownBlockKey,
255 BlockFieldNotDeclared,
257 OrphanBlockLine,
260 ConstraintViolation,
262 RecordTruncated,
264}
265
266impl DiagnosticKind {
267 pub fn as_str(self) -> &'static str {
268 match self {
269 DiagnosticKind::MissingFields => "MissingFields",
270 DiagnosticKind::ExtraFields => "ExtraFields",
271 DiagnosticKind::CastFailed => "CastFailed",
272 DiagnosticKind::UnknownBlockKey => "UnknownBlockKey",
273 DiagnosticKind::BlockFieldNotDeclared => "BlockFieldNotDeclared",
274 DiagnosticKind::OrphanBlockLine => "OrphanBlockLine",
275 DiagnosticKind::ConstraintViolation => "ConstraintViolation",
276 DiagnosticKind::RecordTruncated => "RecordTruncated",
277 }
278 }
279}
280
281impl fmt::Display for DiagnosticKind {
282 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
283 f.write_str(self.as_str())
284 }
285}
286
287#[derive(Debug, Clone, PartialEq, Eq)]
290pub struct Diagnostic {
291 pub record_index: usize,
292 pub line: usize,
293 pub kind: DiagnosticKind,
294 pub message: String,
295}
296
297impl fmt::Display for Diagnostic {
298 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
299 write!(
300 f,
301 "{} (record {}, line {}): {}",
302 self.kind, self.record_index, self.line, self.message
303 )
304 }
305}
306
307#[derive(Debug, Clone, Default)]
311pub struct SynxlOptions {
312 pub validate: bool,
315}
316
317#[derive(Debug, Clone, PartialEq)]
321pub struct SynxlRecord {
322 pub index: usize,
324 pub line: usize,
326 pub field_list: usize,
328 pub value: Value,
330 pub diagnostics: Vec<Diagnostic>,
332}
333
334#[derive(Debug, Clone, PartialEq)]
336pub struct SynxlDocument {
337 pub version: u32,
339 pub records: Vec<Value>,
341 pub field_lists: Vec<FieldList>,
343 pub record_field_lists: Vec<usize>,
345 pub record_lines: Vec<usize>,
347 pub diagnostics: Vec<Diagnostic>,
349}
350
351impl SynxlDocument {
352 pub fn len(&self) -> usize {
353 self.records.len()
354 }
355
356 pub fn is_empty(&self) -> bool {
357 self.records.is_empty()
358 }
359
360 pub fn field_list_for(&self, index: usize) -> Option<&FieldList> {
362 self.record_field_lists
363 .get(index)
364 .and_then(|i| self.field_lists.get(*i))
365 }
366
367 pub fn to_json(&self) -> String {
369 records_to_json_array(&self.records)
370 }
371
372 pub fn to_ndjson(&self) -> String {
374 records_to_ndjson(&self.records)
375 }
376
377 pub fn to_synxl(&self) -> Result<String, SynxlError> {
382 write_document(self)
383 }
384}
385
386pub fn records_to_json_array(records: &[Value]) -> String {
388 let mut out = String::with_capacity(records.len() * 64 + 2);
389 out.push('[');
390 for (i, rec) in records.iter().enumerate() {
391 if i > 0 {
392 out.push(',');
393 }
394 crate::write_json(&mut out, rec);
395 }
396 out.push(']');
397 out
398}
399
400pub fn records_to_ndjson(records: &[Value]) -> String {
403 let mut out = String::with_capacity(records.len() * 64);
404 for rec in records {
405 crate::write_json(&mut out, rec);
406 out.push('\n');
407 }
408 out
409}
410
411#[derive(Debug, Clone, Copy)]
419struct Line {
420 start: usize,
422 end: usize,
424 no: usize,
426}
427
428impl Line {
429 #[inline]
431 fn raw<'t>(&self, text: &'t str) -> &'t str {
432 &text[self.start..self.end]
433 }
434
435 #[inline]
436 fn indent(&self, text: &str) -> usize {
437 let raw = self.raw(text);
438 raw.len() - raw.trim_start().len()
439 }
440}
441
442enum LineKind {
444 Skip,
447 Record,
449}
450
451struct RecordFrame<'t> {
457 line: &'t str,
459 line_no: usize,
461 block: &'t str,
463 block_first_no: usize,
465 truncated_total: Option<usize>,
467}
468
469#[derive(Debug)]
476struct ReaderCore {
477 pos: usize,
479 line_no: usize,
481 pending: Option<Line>,
483 pending_diagnostics: Vec<Diagnostic>,
486 in_block_comment: bool,
487 field_lists: Vec<FieldList>,
488 current: Option<usize>,
489 record_index: usize,
490 version: u32,
491 done: bool,
492 opts: SynxlOptions,
493}
494
495impl ReaderCore {
496 fn empty(opts: SynxlOptions) -> Self {
498 Self {
499 pos: 0,
500 line_no: 1,
501 pending: None,
502 pending_diagnostics: Vec::new(),
503 in_block_comment: false,
504 field_lists: Vec::new(),
505 current: None,
506 record_index: 0,
507 version: 0,
508 done: false,
509 opts,
510 }
511 }
512
513 fn start(text: &str, opts: SynxlOptions) -> Result<Self, SynxlError> {
515 let mut core = Self {
516 pos: if text.starts_with('\u{feff}') {
520 '\u{feff}'.len_utf8()
521 } else {
522 0
523 },
524 line_no: 1,
525 pending: None,
526 pending_diagnostics: Vec::new(),
527 in_block_comment: false,
528 field_lists: Vec::new(),
529 current: None,
530 record_index: 0,
531 version: 0,
532 done: false,
533 opts,
534 };
535 core.read_prologue(text)?;
536 Ok(core)
537 }
538
539 fn read_line(&mut self, text: &str) -> Option<Line> {
541 if let Some(line) = self.pending.take() {
542 return Some(line);
543 }
544 let bytes = text.as_bytes();
545 if self.pos >= bytes.len() {
546 return None;
547 }
548 let start = self.pos;
549 let (mut end, next) = match memchr(b'\n', &bytes[start..]) {
550 Some(rel) => (start + rel, start + rel + 1),
551 None => (bytes.len(), bytes.len()),
552 };
553 if end > start && bytes[end - 1] == b'\r' {
555 end -= 1;
556 }
557 self.pos = next;
558 let no = self.line_no;
559 self.line_no += 1;
560 Some(Line { start, end, no })
561 }
562
563 fn next_item(&mut self, text: &str) -> Option<Result<SynxlRecord, SynxlError>> {
565 if self.done {
566 return None;
567 }
568 match self.next_record(text) {
569 Ok(Some(rec)) => Some(Ok(rec)),
570 Ok(None) => {
571 self.done = true;
572 None
573 }
574 Err(err) => {
575 self.done = true;
577 Some(Err(err))
578 }
579 }
580 }
581
582 fn prologue_skip(&mut self, trimmed: &str) -> bool {
587 if trimmed.is_empty() {
588 return true;
589 }
590 if trimmed == "###" {
592 self.in_block_comment = !self.in_block_comment;
593 return true;
594 }
595 if self.in_block_comment {
596 return true;
597 }
598 trimmed.starts_with('#') || trimmed.starts_with("//")
599 }
600
601 fn missing_prologue(last_line: usize) -> SynxlError {
603 SynxlError::new(
604 SynxlErrorKind::MissingPrologue,
605 last_line.max(1),
606 "document has no `!synxl <version>` prologue",
607 )
608 }
609
610 fn read_prologue(&mut self, text: &str) -> Result<(), SynxlError> {
612 while let Some(line) = self.read_line(text) {
613 let trimmed = line.raw(text).trim();
614 if self.prologue_skip(trimmed) {
615 continue;
616 }
617 self.version = parse_prologue(trimmed, line.no)?;
618 return Ok(());
619 }
620 Err(Self::missing_prologue(self.line_no.saturating_sub(1)))
621 }
622
623 fn note_orphan(&mut self, trimmed: &str, no: usize) {
625 self.pending_diagnostics.push(Diagnostic {
626 record_index: self.record_index,
627 line: no,
628 kind: DiagnosticKind::OrphanBlockLine,
629 message: format!("indented line `{}` has no open record; discarded", elide(trimmed)),
630 });
631 }
632
633 fn classify(&mut self, trimmed: &str, no: usize) -> Result<LineKind, SynxlError> {
639 if trimmed == "###" {
640 self.in_block_comment = !self.in_block_comment;
641 return Ok(LineKind::Skip);
642 }
643 if self.in_block_comment {
644 return Ok(LineKind::Skip);
645 }
646
647 if trimmed.starts_with('!') {
649 if let Some(rest) = trimmed.strip_prefix("!fields") {
650 if rest.is_empty() || starts_with_wsp(rest) {
651 let list = parse_field_list(rest.trim(), trimmed, no)?;
652 self.push_field_list(list)?;
653 return Ok(LineKind::Skip);
654 }
655 }
656 if let Some(rest) = trimmed.strip_prefix("!synxl") {
657 if rest.is_empty() || starts_with_wsp(rest) {
658 parse_prologue(trimmed, no)?;
662 return Ok(LineKind::Skip);
663 }
664 }
665 return Err(SynxlError::new(
670 SynxlErrorKind::UnknownDirective,
671 no,
672 format!(
673 "`{}` is neither a prologue nor a field list; a record starting with `!` must quote it",
674 elide(trimmed)
675 ),
676 ));
677 }
678 if trimmed.starts_with('#') || trimmed.starts_with("//") {
679 return Ok(LineKind::Skip);
680 }
681
682 Ok(LineKind::Record)
685 }
686
687 fn require_field_list(&self, no: usize) -> Result<usize, SynxlError> {
689 self.current.ok_or_else(|| {
690 SynxlError::new(
691 SynxlErrorKind::NoFieldList,
692 no,
693 "record line with no `!fields` in effect",
694 )
695 })
696 }
697
698 fn push_field_list(&mut self, list: FieldList) -> Result<(), SynxlError> {
700 if self.field_lists.len() >= MAX_SYNXL_FIELD_LISTS {
701 return Err(SynxlError::new(
702 SynxlErrorKind::LimitExceeded,
703 list.line,
704 format!("more than {} field lists in one document", MAX_SYNXL_FIELD_LISTS),
705 ));
706 }
707 self.field_lists.push(list);
708 self.current = Some(self.field_lists.len() - 1);
709 Ok(())
710 }
711
712 fn next_record(&mut self, text: &str) -> Result<Option<SynxlRecord>, SynxlError> {
714 loop {
715 let line = match self.read_line(text) {
716 Some(l) => l,
717 None => return Ok(None),
718 };
719 let trimmed = line.raw(text).trim();
720 if trimmed.is_empty() {
721 continue;
722 }
723 if line.indent(text) > 0 {
727 self.note_orphan(trimmed, line.no);
728 continue;
729 }
730 if let LineKind::Skip = self.classify(trimmed, line.no)? {
731 continue;
732 }
733
734 let fl_idx = self.require_field_list(line.no)?;
735
736 let mut block_start: Option<usize> = None;
740 let mut block_end: Option<usize> = None;
741 let mut block_first_no = 0usize;
742 loop {
743 let next = match self.read_line(text) {
744 Some(l) => l,
745 None => break,
746 };
747 if next.raw(text).trim().is_empty() {
748 continue;
749 }
750 if next.indent(text) == 0 {
751 self.pending = Some(next);
752 break;
753 }
754 if block_start.is_none() {
755 block_start = Some(next.start);
756 block_first_no = next.no;
757 }
758 block_end = Some(next.end);
759 }
760 let block_raw = match (block_start, block_end) {
764 (Some(s), Some(e)) => &text[s..e],
765 _ => "",
766 };
767
768 let mut record_line = line.raw(text);
771 let mut block_text = block_raw;
772 let total = record_line.len().saturating_add(block_text.len());
773 let mut truncated_total = None;
774 if total > MAX_SYNXL_RECORD_BYTES {
775 truncated_total = Some(total);
776 if record_line.len() >= MAX_SYNXL_RECORD_BYTES {
777 record_line = truncate_utf8(record_line, MAX_SYNXL_RECORD_BYTES);
778 block_text = "";
779 } else {
780 block_text =
781 truncate_utf8(block_text, MAX_SYNXL_RECORD_BYTES - record_line.len());
782 }
783 }
784
785 return self
786 .build(
787 fl_idx,
788 RecordFrame {
789 line: record_line,
790 line_no: line.no,
791 block: block_text,
792 block_first_no,
793 truncated_total,
794 },
795 )
796 .map(Some);
797 }
798 }
799
800 fn build(&mut self, fl_idx: usize, frame: RecordFrame<'_>) -> Result<SynxlRecord, SynxlError> {
805 let RecordFrame {
806 line: record_line,
807 line_no,
808 block: block_text,
809 block_first_no,
810 truncated_total,
811 } = frame;
812
813 let index = self.record_index;
814 self.record_index += 1;
815 let mut diagnostics: Vec<Diagnostic> = std::mem::take(&mut self.pending_diagnostics);
818 if let Some(total) = truncated_total {
819 diagnostics.push(Diagnostic {
820 record_index: index,
821 line: line_no,
822 kind: DiagnosticKind::RecordTruncated,
823 message: format!(
824 "record is {} bytes, truncated to the {} byte limit",
825 total, MAX_SYNXL_RECORD_BYTES
826 ),
827 });
828 }
829
830 let block_root = if block_text.trim().is_empty() {
833 None
834 } else {
835 match parser::parse_with(block_text, ParserOptions { directives: false }).root {
838 Value::Object(map) => Some(map),
839 _ => None,
840 }
841 };
842
843 let fl = &self.field_lists[fl_idx];
844 let mut obj: HashMap<String, Value> = HashMap::with_capacity(fl.fields.len());
845
846 if record_line.trim() == ";" {
847 for field in fl.fields.iter().filter(|f| !f.block) {
853 obj.insert(field.name.clone(), Value::Null);
854 }
855 } else {
856 let mut inline_fields = fl.fields.iter().filter(|f| !f.block);
858 let mut parts = PartSplitter::new(record_line);
859 let mut part_count = 0usize;
860 loop {
861 let part = match parts.next() {
862 Some(p) => p,
863 None => break,
864 };
865 part_count += 1;
866 match inline_fields.next() {
867 Some(field) => {
868 let value = cast_part(field, part, index, line_no, &mut diagnostics);
869 obj.insert(field.name.clone(), value);
870 }
871 None => {}
873 }
874 }
875 let mut missing = 0usize;
876 for field in inline_fields {
877 obj.insert(field.name.clone(), Value::Null);
878 missing += 1;
879 }
880 if missing > 0 {
881 diagnostics.push(Diagnostic {
882 record_index: index,
883 line: line_no,
884 kind: DiagnosticKind::MissingFields,
885 message: format!(
886 "record has {} inline part(s), field list declares {}; {} trailing field(s) set to null",
887 part_count,
888 fl.arity(),
889 missing
890 ),
891 });
892 } else if part_count > fl.arity() {
893 diagnostics.push(Diagnostic {
894 record_index: index,
895 line: line_no,
896 kind: DiagnosticKind::ExtraFields,
897 message: format!(
898 "record has {} inline part(s), field list declares {}; {} discarded",
899 part_count,
900 fl.arity(),
901 part_count - fl.arity()
902 ),
903 });
904 }
905 }
906
907 for field in fl.fields.iter().filter(|f| f.block) {
909 obj.insert(field.name.clone(), Value::Null);
910 }
911
912 let mut key_lines: Option<HashMap<&str, usize>> = None;
916
917 if let Some(map) = block_root {
920 let mut entries: Vec<(String, Value)> = map.into_iter().collect();
921 entries.sort_unstable_by(|a, b| a.0.cmp(&b.0));
922 for (key, value) in entries {
923 match fl.get(&key) {
924 Some(field) if field.block => {
925 obj.insert(field.name.clone(), value);
926 }
927 Some(_) => {
928 let at = key_lines
929 .get_or_insert_with(|| block_key_lines(block_text, block_first_no))
930 .get(key.as_str())
931 .copied()
932 .unwrap_or(line_no);
933 diagnostics.push(Diagnostic {
934 record_index: index,
935 line: at,
936 kind: DiagnosticKind::BlockFieldNotDeclared,
937 message: format!(
938 "block key `{}` matches a field that is not declared [block]; inline value kept",
939 key
940 ),
941 });
942 }
943 None => {
944 let at = key_lines
945 .get_or_insert_with(|| block_key_lines(block_text, block_first_no))
946 .get(key.as_str())
947 .copied()
948 .unwrap_or(line_no);
949 diagnostics.push(Diagnostic {
950 record_index: index,
951 line: at,
952 kind: DiagnosticKind::UnknownBlockKey,
953 message: format!("block key `{}` matches no declared field", key),
954 });
955 }
956 }
957 }
958 }
959
960 if self.opts.validate {
962 for field in fl.fields.iter() {
963 if let Some(value) = obj.get(&field.name) {
964 if let Some(msg) = check_constraints(&field.name, value, &field.constraints) {
965 let at = if field.block {
968 key_lines
969 .get_or_insert_with(|| block_key_lines(block_text, block_first_no))
970 .get(field.name.as_str())
971 .copied()
972 .unwrap_or(line_no)
973 } else {
974 line_no
975 };
976 diagnostics.push(Diagnostic {
977 record_index: index,
978 line: at,
979 kind: DiagnosticKind::ConstraintViolation,
980 message: msg,
981 });
982 }
983 }
984 }
985 }
986
987 Ok(SynxlRecord {
988 index,
989 line: line_no,
990 field_list: fl_idx,
991 value: Value::Object(obj),
992 diagnostics,
993 })
994 }
995}
996
997#[derive(Debug)]
1019pub struct SynxlReader<'a> {
1020 text: &'a str,
1021 core: ReaderCore,
1022}
1023
1024impl<'a> SynxlReader<'a> {
1025 pub fn new(text: &'a str) -> Result<Self, SynxlError> {
1027 Self::with_options(text, SynxlOptions::default())
1028 }
1029
1030 pub fn with_options(text: &'a str, opts: SynxlOptions) -> Result<Self, SynxlError> {
1032 Ok(Self { text, core: ReaderCore::start(text, opts)? })
1033 }
1034
1035 pub fn version(&self) -> u32 {
1037 self.core.version
1038 }
1039
1040 pub fn field_lists(&self) -> &[FieldList] {
1042 &self.core.field_lists
1043 }
1044
1045 pub fn field_list(&self) -> Option<&FieldList> {
1047 self.core.current.map(|i| &self.core.field_lists[i])
1048 }
1049
1050 pub fn into_field_lists(self) -> Vec<FieldList> {
1052 self.core.field_lists
1053 }
1054
1055 pub fn trailing_diagnostics(&self) -> &[Diagnostic] {
1059 &self.core.pending_diagnostics
1060 }
1061}
1062
1063impl Iterator for SynxlReader<'_> {
1064 type Item = Result<SynxlRecord, SynxlError>;
1065
1066 fn next(&mut self) -> Option<Self::Item> {
1067 self.core.next_item(self.text)
1068 }
1069}
1070
1071#[derive(Debug)]
1092pub struct SynxlReaderOwned {
1093 text: String,
1094 core: ReaderCore,
1095}
1096
1097impl SynxlReaderOwned {
1098 pub fn new(text: String) -> Result<Self, SynxlError> {
1100 Self::with_options(text, SynxlOptions::default())
1101 }
1102
1103 pub fn with_options(text: String, opts: SynxlOptions) -> Result<Self, SynxlError> {
1105 let core = ReaderCore::start(&text, opts)?;
1106 Ok(Self { text, core })
1107 }
1108
1109 pub fn version(&self) -> u32 {
1111 self.core.version
1112 }
1113
1114 pub fn field_lists(&self) -> &[FieldList] {
1116 &self.core.field_lists
1117 }
1118
1119 pub fn field_list(&self) -> Option<&FieldList> {
1121 self.core.current.map(|i| &self.core.field_lists[i])
1122 }
1123
1124 pub fn into_field_lists(self) -> Vec<FieldList> {
1126 self.core.field_lists
1127 }
1128
1129 pub fn trailing_diagnostics(&self) -> &[Diagnostic] {
1132 &self.core.pending_diagnostics
1133 }
1134
1135 pub fn text(&self) -> &str {
1137 &self.text
1138 }
1139
1140 pub fn into_text(self) -> String {
1142 self.text
1143 }
1144}
1145
1146impl Iterator for SynxlReaderOwned {
1147 type Item = Result<SynxlRecord, SynxlError>;
1148
1149 fn next(&mut self) -> Option<Self::Item> {
1150 self.core.next_item(&self.text)
1151 }
1152}
1153
1154#[derive(Debug)]
1164pub enum SynxlStreamError {
1165 Format(SynxlError),
1167 Io(std::io::Error),
1169}
1170
1171impl SynxlStreamError {
1172 pub fn as_format(&self) -> Option<&SynxlError> {
1174 match self {
1175 SynxlStreamError::Format(e) => Some(e),
1176 SynxlStreamError::Io(_) => None,
1177 }
1178 }
1179
1180 pub fn as_io(&self) -> Option<&std::io::Error> {
1182 match self {
1183 SynxlStreamError::Io(e) => Some(e),
1184 SynxlStreamError::Format(_) => None,
1185 }
1186 }
1187}
1188
1189impl fmt::Display for SynxlStreamError {
1190 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1191 match self {
1192 SynxlStreamError::Format(e) => write!(f, "{}", e),
1193 SynxlStreamError::Io(e) => write!(f, "I/O error while reading SYNXL: {}", e),
1194 }
1195 }
1196}
1197
1198impl std::error::Error for SynxlStreamError {
1199 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
1200 match self {
1201 SynxlStreamError::Format(e) => Some(e),
1202 SynxlStreamError::Io(e) => Some(e),
1203 }
1204 }
1205}
1206
1207impl From<SynxlError> for SynxlStreamError {
1208 fn from(e: SynxlError) -> Self {
1209 SynxlStreamError::Format(e)
1210 }
1211}
1212
1213impl From<std::io::Error> for SynxlStreamError {
1214 fn from(e: std::io::Error) -> Self {
1215 SynxlStreamError::Io(e)
1216 }
1217}
1218
1219#[derive(Debug)]
1250pub struct SynxlStreamReader<R: std::io::BufRead> {
1251 inner: R,
1252 core: ReaderCore,
1253 scratch: Vec<u8>,
1255 cur: String,
1257 cur_no: usize,
1259 cur_dropped: usize,
1261 lookahead: Option<(String, usize, usize)>,
1263 line_no: usize,
1265 record: String,
1267 block: String,
1268 deferred: String,
1271 done: bool,
1272}
1273
1274impl<R: std::io::BufRead> SynxlStreamReader<R> {
1275 pub fn new(inner: R) -> Result<Self, SynxlStreamError> {
1277 Self::with_options(inner, SynxlOptions::default())
1278 }
1279
1280 pub fn with_options(inner: R, opts: SynxlOptions) -> Result<Self, SynxlStreamError> {
1282 let mut reader = Self {
1283 inner,
1284 core: ReaderCore::empty(opts),
1285 scratch: Vec::with_capacity(256),
1286 cur: String::with_capacity(256),
1287 cur_no: 0,
1288 cur_dropped: 0,
1289 lookahead: None,
1290 line_no: 0,
1291 record: String::new(),
1292 block: String::new(),
1293 deferred: String::new(),
1294 done: false,
1295 };
1296 reader.read_prologue()?;
1297 Ok(reader)
1298 }
1299
1300 pub fn version(&self) -> u32 {
1302 self.core.version
1303 }
1304
1305 pub fn field_lists(&self) -> &[FieldList] {
1307 &self.core.field_lists
1308 }
1309
1310 pub fn field_list(&self) -> Option<&FieldList> {
1312 self.core.current.map(|i| &self.core.field_lists[i])
1313 }
1314
1315 pub fn into_field_lists(self) -> Vec<FieldList> {
1317 self.core.field_lists
1318 }
1319
1320 pub fn trailing_diagnostics(&self) -> &[Diagnostic] {
1323 &self.core.pending_diagnostics
1324 }
1325
1326 pub fn into_inner(self) -> R {
1328 self.inner
1329 }
1330
1331 fn advance(&mut self) -> Result<bool, SynxlStreamError> {
1333 if let Some((text, no, dropped)) = self.lookahead.take() {
1334 self.cur = text;
1335 self.cur_no = no;
1336 self.cur_dropped = dropped;
1337 return Ok(true);
1338 }
1339 let dropped =
1340 match read_capped_line(&mut self.inner, &mut self.scratch, MAX_SYNXL_RECORD_BYTES)? {
1341 Some(d) => d,
1342 None => return Ok(false),
1343 };
1344 if dropped > 0 {
1347 for _ in 0..3 {
1348 if std::str::from_utf8(&self.scratch).is_ok() {
1349 break;
1350 }
1351 self.scratch.pop();
1352 }
1353 }
1354 let text = std::str::from_utf8(&self.scratch).map_err(|e| {
1355 std::io::Error::new(
1356 std::io::ErrorKind::InvalidData,
1357 format!("line {} is not valid UTF-8 (§3.1): {}", self.line_no + 1, e),
1358 )
1359 })?;
1360 self.cur.clear();
1361 self.cur.push_str(if self.line_no == 0 {
1363 text.strip_prefix('\u{feff}').unwrap_or(text)
1364 } else {
1365 text
1366 });
1367 self.line_no += 1;
1368 self.cur_no = self.line_no;
1369 self.cur_dropped = dropped;
1370 Ok(true)
1371 }
1372
1373 fn push_back(&mut self) {
1375 self.lookahead = Some((std::mem::take(&mut self.cur), self.cur_no, self.cur_dropped));
1376 }
1377
1378 fn read_prologue(&mut self) -> Result<(), SynxlStreamError> {
1380 loop {
1381 if !self.advance()? {
1382 return Err(ReaderCore::missing_prologue(self.line_no).into());
1383 }
1384 let trimmed = self.cur.trim();
1385 if self.core.prologue_skip(trimmed) {
1386 continue;
1387 }
1388 self.core.version = parse_prologue(trimmed, self.cur_no)?;
1389 return Ok(());
1390 }
1391 }
1392
1393 fn next_record(&mut self) -> Result<Option<SynxlRecord>, SynxlStreamError> {
1395 loop {
1396 if !self.advance()? {
1397 return Ok(None);
1398 }
1399 let trimmed = self.cur.trim();
1400 if trimmed.is_empty() {
1401 continue;
1402 }
1403 if self.cur.len() - self.cur.trim_start().len() > 0 {
1405 let no = self.cur_no;
1406 self.core.note_orphan(trimmed, no);
1407 continue;
1408 }
1409 if let LineKind::Skip = self.core.classify(trimmed, self.cur_no)? {
1410 continue;
1411 }
1412
1413 let record_no = self.cur_no;
1414 let fl_idx = self.core.require_field_list(record_no)?;
1415 self.record.clear();
1416 self.record.push_str(&self.cur);
1417 let mut total = self.record.len() + self.cur_dropped;
1420 self.block.clear();
1421 self.deferred.clear();
1422 let mut block_first_no = 0usize;
1423
1424 loop {
1426 if !self.advance()? {
1427 break;
1428 }
1429 if self.cur.trim().is_empty() {
1430 if !self.block.is_empty() {
1434 self.deferred.push('\n');
1435 self.deferred.push_str(&self.cur);
1436 }
1437 continue;
1438 }
1439 if self.cur.len() - self.cur.trim_start().len() == 0 {
1440 self.push_back();
1441 break;
1442 }
1443 if block_first_no == 0 {
1444 block_first_no = self.cur_no;
1445 }
1446 let sep = usize::from(!self.block.is_empty());
1447 total += self.deferred.len() + sep + self.cur.len() + self.cur_dropped;
1448 let room = (MAX_SYNXL_RECORD_BYTES + self.deferred.len())
1451 .saturating_sub(self.record.len() + self.block.len());
1452 if room > self.deferred.len() + sep {
1453 let budget = room - self.deferred.len() - sep;
1454 self.block.push_str(&self.deferred);
1455 if sep == 1 {
1456 self.block.push('\n');
1457 }
1458 let piece_len = truncate_utf8(&self.cur, budget).len();
1459 self.block.push_str(&self.cur[..piece_len]);
1460 }
1461 self.deferred.clear();
1462 }
1463
1464 let truncated_total = if total > MAX_SYNXL_RECORD_BYTES { Some(total) } else { None };
1465 let frame = RecordFrame {
1466 line: &self.record,
1467 line_no: record_no,
1468 block: &self.block,
1469 block_first_no,
1470 truncated_total,
1471 };
1472 return self.core.build(fl_idx, frame).map(Some).map_err(Into::into);
1473 }
1474 }
1475}
1476
1477impl<R: std::io::BufRead> Iterator for SynxlStreamReader<R> {
1478 type Item = Result<SynxlRecord, SynxlStreamError>;
1479
1480 fn next(&mut self) -> Option<Self::Item> {
1481 if self.done {
1482 return None;
1483 }
1484 match self.next_record() {
1485 Ok(Some(rec)) => Some(Ok(rec)),
1486 Ok(None) => {
1487 self.done = true;
1488 None
1489 }
1490 Err(err) => {
1491 self.done = true;
1494 Some(Err(err))
1495 }
1496 }
1497 }
1498}
1499
1500fn read_capped_line<R: std::io::BufRead>(
1506 reader: &mut R,
1507 out: &mut Vec<u8>,
1508 cap: usize,
1509) -> std::io::Result<Option<usize>> {
1510 out.clear();
1511 let mut dropped = 0usize;
1512 let mut saw_any = false;
1513 loop {
1514 let (consumed, done) = {
1515 let available = match reader.fill_buf() {
1516 Ok(b) => b,
1517 Err(ref e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
1518 Err(e) => return Err(e),
1519 };
1520 if available.is_empty() {
1521 break;
1522 }
1523 saw_any = true;
1524 match memchr(b'\n', available) {
1525 Some(i) => {
1526 push_capped(out, &available[..i], cap, &mut dropped);
1527 (i + 1, true)
1528 }
1529 None => {
1530 let len = available.len();
1531 push_capped(out, available, cap, &mut dropped);
1532 (len, false)
1533 }
1534 }
1535 };
1536 reader.consume(consumed);
1537 if done {
1538 break;
1539 }
1540 }
1541 if !saw_any {
1542 return Ok(None);
1543 }
1544 if out.last() == Some(&b'\r') {
1545 out.pop();
1546 }
1547 Ok(Some(dropped))
1548}
1549
1550fn push_capped(out: &mut Vec<u8>, chunk: &[u8], cap: usize, dropped: &mut usize) {
1551 let room = cap.saturating_sub(out.len());
1552 let take = room.min(chunk.len());
1553 out.extend_from_slice(&chunk[..take]);
1554 *dropped += chunk.len() - take;
1555}
1556
1557pub fn parse_lines(text: &str) -> Result<SynxlDocument, SynxlError> {
1564 parse_lines_with(text, &SynxlOptions::default())
1565}
1566
1567pub fn parse_lines_with(text: &str, opts: &SynxlOptions) -> Result<SynxlDocument, SynxlError> {
1569 let mut reader = SynxlReader::with_options(text, opts.clone())?;
1570 let mut records = Vec::new();
1571 let mut record_field_lists = Vec::new();
1572 let mut record_lines = Vec::new();
1573 let mut diagnostics = Vec::new();
1574
1575 loop {
1576 let item = match reader.next() {
1577 Some(item) => item,
1578 None => break,
1579 };
1580 let mut rec = item?;
1581 if records.len() >= MAX_SYNXL_RECORDS {
1583 return Err(SynxlError::new(
1584 SynxlErrorKind::LimitExceeded,
1585 rec.line,
1586 format!("more than {} records in an in-memory parse", MAX_SYNXL_RECORDS),
1587 ));
1588 }
1589 records.push(rec.value);
1590 record_field_lists.push(rec.field_list);
1591 record_lines.push(rec.line);
1592 diagnostics.append(&mut rec.diagnostics);
1593 }
1594
1595 diagnostics.extend_from_slice(reader.trailing_diagnostics());
1598
1599 let version = reader.version();
1600 Ok(SynxlDocument {
1601 version,
1602 records,
1603 field_lists: reader.into_field_lists(),
1604 record_field_lists,
1605 record_lines,
1606 diagnostics,
1607 })
1608}
1609
1610fn block_key_lines(block: &str, first_line_no: usize) -> HashMap<&str, usize> {
1618 let mut min_indent = usize::MAX;
1619 for line in block.split('\n') {
1620 let line = line.strip_suffix('\r').unwrap_or(line);
1621 if line.trim().is_empty() {
1622 continue;
1623 }
1624 min_indent = min_indent.min(line.len() - line.trim_start().len());
1625 }
1626
1627 let mut map: HashMap<&str, usize> = HashMap::new();
1628 for (i, line) in block.split('\n').enumerate() {
1629 let line = line.strip_suffix('\r').unwrap_or(line);
1630 let trimmed = line.trim();
1631 if trimmed.is_empty() || line.len() - line.trim_start().len() != min_indent {
1632 continue;
1633 }
1634 if trimmed.starts_with("- ") || trimmed.starts_with('!') {
1637 continue;
1638 }
1639 match trimmed.as_bytes()[0] {
1640 b'[' | b':' | b'-' | b'#' | b'/' | b'(' => continue,
1641 _ => {}
1642 }
1643 let key_end = trimmed
1644 .find(|c: char| c == ' ' || c == '\t' || c == '[' || c == ':' || c == '(')
1645 .unwrap_or(trimmed.len());
1646 map.entry(&trimmed[..key_end]).or_insert(first_line_no + i);
1647 }
1648 map
1649}
1650
1651fn parse_prologue(trimmed: &str, line: usize) -> Result<u32, SynxlError> {
1654 let rest = match trimmed.strip_prefix("!synxl") {
1655 Some(r) if starts_with_wsp(r) => r,
1656 _ => {
1657 return Err(SynxlError::new(
1658 SynxlErrorKind::MissingPrologue,
1659 line,
1660 format!("expected `!synxl <version>`, found `{}`", elide(trimmed)),
1661 ))
1662 }
1663 };
1664 let version = rest.trim();
1665 if version.is_empty() || !version.bytes().all(|b| b.is_ascii_digit()) {
1667 return Err(SynxlError::new(
1668 SynxlErrorKind::MissingPrologue,
1669 line,
1670 format!("prologue version must be a decimal integer, found `{}`", elide(version)),
1671 ));
1672 }
1673 match version.parse::<u32>() {
1674 Ok(v) if v == SYNXL_VERSION => Ok(v),
1675 _ => Err(SynxlError::new(
1676 SynxlErrorKind::UnsupportedVersion,
1677 line,
1678 format!("SYNXL version `{}` is not supported (this build implements {})", version, SYNXL_VERSION),
1679 )),
1680 }
1681}
1682
1683fn parse_field_list(rest: &str, source: &str, line: usize) -> Result<FieldList, SynxlError> {
1690 if rest.is_empty() {
1691 return Err(SynxlError::new(
1692 SynxlErrorKind::MalformedFieldList,
1693 line,
1694 "`!fields` declares no fields",
1695 ));
1696 }
1697
1698 let mut fields: Vec<FieldDecl> = Vec::new();
1699 for decl in rest.split(';') {
1700 let decl = decl.trim();
1701 if decl.is_empty() {
1702 return Err(SynxlError::new(
1703 SynxlErrorKind::MalformedFieldList,
1704 line,
1705 "empty field declaration",
1706 ));
1707 }
1708 if fields.len() >= MAX_SYNXL_FIELDS {
1709 return Err(SynxlError::new(
1710 SynxlErrorKind::LimitExceeded,
1711 line,
1712 format!("more than {} fields in one field list", MAX_SYNXL_FIELDS),
1713 ));
1714 }
1715 let field = parse_field_decl(decl, line)?;
1716 if fields.iter().any(|f| f.name == field.name) {
1718 return Err(SynxlError::new(
1719 SynxlErrorKind::DuplicateField,
1720 line,
1721 format!("duplicate field name `{}`", field.name),
1722 ));
1723 }
1724 fields.push(field);
1725 }
1726
1727 let list = FieldList::new(fields);
1728 if list.arity() == 0 {
1732 return Err(SynxlError::new(
1733 SynxlErrorKind::ZeroArity,
1734 line,
1735 "field list has arity 0 — every field is declared [block]; at least one inline field is required",
1736 ));
1737 }
1738 let mut list = list;
1739 list.line = line;
1740 list.source = source.to_string();
1741 Ok(list)
1742}
1743
1744fn parse_field_decl(decl: &str, line: usize) -> Result<FieldDecl, SynxlError> {
1746 let bytes = decl.as_bytes();
1747 let len = bytes.len();
1748
1749 let mut pos = 0usize;
1751 while pos < len {
1752 let ch = bytes[pos];
1753 if ch == b' ' || ch == b'\t' || ch == b'[' || ch == b'(' || ch == b':' {
1754 break;
1755 }
1756 pos += 1;
1757 }
1758 let name = &decl[..pos];
1759 if name.is_empty() {
1760 return Err(SynxlError::new(
1761 SynxlErrorKind::MalformedFieldList,
1762 line,
1763 format!("field declaration `{}` has no name", elide(decl)),
1764 ));
1765 }
1766 if name.len() > MAX_SYNXL_FIELD_NAME_BYTES {
1767 return Err(SynxlError::new(
1768 SynxlErrorKind::LimitExceeded,
1769 line,
1770 format!(
1771 "field name is {} bytes, limit is {}",
1772 name.len(),
1773 MAX_SYNXL_FIELD_NAME_BYTES
1774 ),
1775 ));
1776 }
1777
1778 let mut type_hint = None;
1780 if pos < len && bytes[pos] == b'(' {
1781 let start = pos + 1;
1782 match decl[start..].find(')') {
1783 Some(rel) => {
1784 type_hint = Some(decl[start..start + rel].trim().to_string());
1785 pos = start + rel + 1;
1786 }
1787 None => {
1788 return Err(SynxlError::new(
1789 SynxlErrorKind::MalformedFieldList,
1790 line,
1791 format!("unterminated `(` in field declaration `{}`", elide(decl)),
1792 ))
1793 }
1794 }
1795 }
1796
1797 let mut constraints = Constraints::default();
1799 let mut block = false;
1800 if pos < len && bytes[pos] == b'[' {
1801 let cstart = pos + 1;
1802 let mut depth = 1usize;
1803 let mut scan = cstart;
1804 while scan < len {
1805 match bytes[scan] {
1806 b'[' => depth += 1,
1807 b']' => {
1808 depth -= 1;
1809 if depth == 0 {
1810 break;
1811 }
1812 }
1813 _ => {}
1814 }
1815 scan += 1;
1816 }
1817 if depth != 0 {
1818 return Err(SynxlError::new(
1819 SynxlErrorKind::MalformedFieldList,
1820 line,
1821 format!("unterminated `[` in field declaration `{}`", elide(decl)),
1822 ));
1823 }
1824 let raw = &decl[cstart..scan];
1825 constraints = parser::parse_constraints(raw);
1827 block = raw
1830 .split(',')
1831 .map(|p| p.trim())
1832 .any(|p| p == "block");
1833 pos = scan + 1;
1834 }
1835
1836 let tail = decl[pos..].trim();
1838 if !tail.is_empty() {
1839 if tail.starts_with(':') {
1841 return Err(SynxlError::new(
1842 SynxlErrorKind::MarkerChain,
1843 line,
1844 format!("marker chain `{}` is not allowed in a field declaration", elide(tail)),
1845 ));
1846 }
1847 return Err(SynxlError::new(
1848 SynxlErrorKind::MalformedFieldList,
1849 line,
1850 format!("trailing `{}` in field declaration `{}`", elide(tail), elide(decl)),
1851 ));
1852 }
1853
1854 for hint in [type_hint.as_deref(), constraints.type_name.as_deref()]
1857 .into_iter()
1858 .flatten()
1859 {
1860 if is_non_deterministic_hint(hint) {
1861 return Err(SynxlError::new(
1862 SynxlErrorKind::NonDeterministicHint,
1863 line,
1864 format!("non-deterministic type hint `{}` on field `{}`", hint, name),
1865 ));
1866 }
1867 }
1868
1869 if block && (type_hint.is_some() || constraints.type_name.is_some()) {
1871 return Err(SynxlError::new(
1872 SynxlErrorKind::BlockWithType,
1873 line,
1874 format!("field `{}` combines `block` with a type", name),
1875 ));
1876 }
1877
1878 Ok(FieldDecl {
1879 name: name.to_string(),
1880 type_hint,
1881 constraints,
1882 block,
1883 })
1884}
1885
1886fn is_non_deterministic_hint(hint: &str) -> bool {
1887 matches!(hint, "random" | "random:int" | "random:float" | "random:bool")
1888}
1889
1890#[derive(Debug, Clone, Copy, PartialEq)]
1894struct Part<'a> {
1895 text: &'a str,
1896 quoted: bool,
1897}
1898
1899struct PartSplitter<'a> {
1905 s: &'a str,
1906 b: &'a [u8],
1907 pos: usize,
1908 finished: bool,
1909}
1910
1911impl<'a> PartSplitter<'a> {
1912 fn new(s: &'a str) -> Self {
1913 Self { s, b: s.as_bytes(), pos: 0, finished: false }
1914 }
1915}
1916
1917impl<'a> Iterator for PartSplitter<'a> {
1918 type Item = Part<'a>;
1919
1920 fn next(&mut self) -> Option<Part<'a>> {
1921 if self.finished {
1922 return None;
1923 }
1924 let len = self.b.len();
1925 let mut i = self.pos;
1926
1927 while i < len && (self.b[i] == b' ' || self.b[i] == b'\t') {
1929 i += 1;
1930 }
1931
1932 if i < len && (self.b[i] == b'"' || self.b[i] == b'\'') {
1934 let quote = self.b[i];
1935 if let Some(rel) = memchr(quote, &self.b[i + 1..]) {
1936 let close = i + 1 + rel;
1937 let mut j = close + 1;
1938 while j < len && (self.b[j] == b' ' || self.b[j] == b'\t') {
1939 j += 1;
1940 }
1941 if j >= len || self.b[j] == b';' {
1942 let part = Part { text: &self.s[i + 1..close], quoted: true };
1945 if j >= len {
1946 self.finished = true;
1947 self.pos = len;
1948 } else {
1949 self.pos = j + 1;
1950 }
1951 return Some(part);
1952 }
1953 }
1954 }
1955
1956 let end = match memchr(b';', &self.b[i..]) {
1959 Some(rel) => i + rel,
1960 None => len,
1961 };
1962 let text = trim_wsp(&self.s[i..end]);
1963 if end >= len {
1964 self.finished = true;
1965 self.pos = len;
1966 } else {
1967 self.pos = end + 1;
1968 }
1969 Some(Part { text, quoted: false })
1970 }
1971}
1972
1973fn cast_part(
1977 field: &FieldDecl,
1978 part: Part<'_>,
1979 record_index: usize,
1980 line: usize,
1981 diagnostics: &mut Vec<Diagnostic>,
1982) -> Value {
1983 if part.quoted {
1985 return Value::String(part.text.to_string());
1986 }
1987 if part.text.is_empty() {
1989 return Value::Null;
1990 }
1991 match field.type_name() {
1992 Some(hint) => match cast_typed_checked(part.text, hint) {
1994 Some(v) => v,
1995 None => {
1996 diagnostics.push(Diagnostic {
1997 record_index,
1998 line,
1999 kind: DiagnosticKind::CastFailed,
2000 message: format!(
2001 "field `{}`: `{}` is not a valid {}",
2002 field.name,
2003 elide(part.text),
2004 hint
2005 ),
2006 });
2007 Value::Null
2008 }
2009 },
2010 None => cast_inline(part.text),
2012 }
2013}
2014
2015fn cast_inline(raw: &str) -> Value {
2022 if is_quote_wrapped(raw) {
2023 return Value::String(raw.to_string());
2024 }
2025 parser::cast(raw)
2026}
2027
2028fn cast_typed_checked(raw: &str, hint: &str) -> Option<Value> {
2030 match hint {
2031 "int" => raw.parse::<i64>().ok().map(Value::Int),
2032 "float" => raw.parse::<f64>().ok().filter(|f| f.is_finite()).map(Value::Float),
2035 "bool" => match raw {
2036 "true" => Some(Value::Bool(true)),
2037 "false" => Some(Value::Bool(false)),
2038 _ => None,
2039 },
2040 "string" => Some(Value::String(raw.to_string())),
2041 _ => Some(cast_inline(raw)),
2044 }
2045}
2046
2047fn check_constraints(key: &str, value: &Value, c: &Constraints) -> Option<String> {
2051 if c.required {
2052 let empty = matches!(value, Value::Null)
2053 || matches!(value, Value::String(s) if s.is_empty());
2054 if empty {
2055 return Some(format!("`{}` is required", key));
2056 }
2057 }
2058 if matches!(value, Value::Null) {
2060 return None;
2061 }
2062
2063 if let Some(ref type_name) = c.type_name {
2064 let ok = match type_name.as_str() {
2065 "int" => matches!(value, Value::Int(_)),
2066 "float" => matches!(value, Value::Float(_) | Value::Int(_)),
2067 "bool" => matches!(value, Value::Bool(_)),
2068 "string" => matches!(value, Value::String(_)),
2069 _ => true,
2070 };
2071 if !ok {
2072 return Some(format!("`{}` expected type `{}`", key, type_name));
2073 }
2074 }
2075
2076 if let Some(ref enum_values) = c.enum_values {
2077 let as_str = match value {
2078 Value::String(s) => s.clone(),
2079 Value::Int(n) => n.to_string(),
2080 Value::Float(f) => f.to_string(),
2081 Value::Bool(b) => b.to_string(),
2082 _ => String::new(),
2083 };
2084 if !enum_values.contains(&as_str) {
2085 return Some(format!("`{}` must be one of [{}]", key, enum_values.join("|")));
2086 }
2087 }
2088
2089 let num = match value {
2091 Value::Int(n) => Some(*n as f64),
2092 Value::Float(f) => Some(*f),
2093 Value::String(s) if c.min.is_some() || c.max.is_some() => Some(s.len() as f64),
2094 _ => None,
2095 };
2096 if let Some(n) = num {
2097 if let Some(min) = c.min {
2098 if n < min {
2099 return Some(format!("`{}` value {} is below min {}", key, n, min));
2100 }
2101 }
2102 if let Some(max) = c.max {
2103 if n > max {
2104 return Some(format!("`{}` value {} exceeds max {}", key, n, max));
2105 }
2106 }
2107 }
2108
2109 if let Some(ref pattern) = c.pattern {
2110 if pattern.len() <= 256 {
2111 if let Value::String(ref s) = value {
2112 if let Ok(re) = regex::Regex::new(pattern) {
2114 if !re.is_match(s) {
2115 return Some(format!("`{}` does not match pattern /{}/", key, pattern));
2116 }
2117 }
2118 }
2119 }
2120 }
2121
2122 None
2123}
2124
2125pub fn write_document(doc: &SynxlDocument) -> Result<String, SynxlError> {
2135 let mut out = String::with_capacity(doc.records.len() * 64 + 64);
2136 out.push_str("!synxl 1\n");
2137
2138 if doc.records.is_empty() {
2139 if let Some(fl) = doc.field_lists.first() {
2141 write_group(&mut out, fl.fields(), &[])?;
2142 }
2143 return Ok(out);
2144 }
2145
2146 let mut start = 0usize;
2147 while start < doc.records.len() {
2148 let fl_idx = doc.record_field_lists.get(start).copied().unwrap_or(0);
2149 let mut end = start + 1;
2150 while end < doc.records.len()
2151 && doc.record_field_lists.get(end).copied().unwrap_or(0) == fl_idx
2152 {
2153 end += 1;
2154 }
2155 let empty = FieldList::new(Vec::new());
2156 let fl = doc.field_lists.get(fl_idx).unwrap_or(&empty);
2157 write_group(&mut out, fl.fields(), &doc.records[start..end])?;
2158 start = end;
2159 }
2160 Ok(out)
2161}
2162
2163pub fn write_lines(fields: &[FieldDecl], records: &[Value]) -> Result<String, SynxlError> {
2166 let mut out = String::with_capacity(records.len() * 64 + 64);
2167 out.push_str("!synxl 1\n");
2168 write_group(&mut out, fields, records)?;
2169 Ok(out)
2170}
2171
2172fn write_group(
2174 out: &mut String,
2175 fields: &[FieldDecl],
2176 records: &[Value],
2177) -> Result<(), SynxlError> {
2178 let mut block: Vec<bool> = fields
2181 .iter()
2182 .map(|f| {
2183 f.block
2184 || records.iter().any(|r| {
2185 r.as_object()
2186 .and_then(|m| m.get(&f.name))
2187 .map(needs_block)
2188 .unwrap_or(false)
2189 })
2190 })
2191 .collect();
2192
2193 if !fields.is_empty() && block.iter().all(|b| *b) {
2205 let candidate = (0..fields.len()).filter(|i| !fields[*i].block).find(|i| {
2206 !records.iter().any(|r| {
2207 matches!(
2208 r.as_object().and_then(|m| m.get(&fields[*i].name)),
2209 Some(Value::String(s))
2210 if s.contains('\n') || s.contains('\r') || s.contains(';')
2211 )
2212 })
2213 });
2214 match candidate {
2215 Some(i) => block[i] = false,
2216 None => {
2217 return Err(SynxlError::new(
2218 SynxlErrorKind::Unwritable,
2219 0,
2220 "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",
2221 ))
2222 }
2223 }
2224 }
2225
2226 out.push_str("!fields ");
2227 for (i, field) in fields.iter().enumerate() {
2228 if i > 0 {
2229 out.push_str("; ");
2230 }
2231 write_field_decl(out, field, block[i]);
2232 }
2233 out.push('\n');
2234
2235 for record in records {
2236 let map = record.as_object();
2237 let all_null = fields.iter().enumerate().all(|(i, f)| {
2241 block[i]
2242 || map
2243 .and_then(|m| m.get(&f.name))
2244 .map(Value::is_null)
2245 .unwrap_or(true)
2246 });
2247 if all_null {
2248 out.push(';');
2249 } else {
2250 let mut first = true;
2251 for (i, field) in fields.iter().enumerate() {
2252 if block[i] {
2253 continue;
2254 }
2255 if !first {
2256 out.push_str("; ");
2257 }
2258 first = false;
2259 let value = map.and_then(|m| m.get(&field.name)).unwrap_or(&Value::Null);
2260 write_inline(out, value);
2261 }
2262 }
2263 out.push('\n');
2264
2265 for (i, field) in fields.iter().enumerate() {
2266 if !block[i] {
2267 continue;
2268 }
2269 let value = match map.and_then(|m| m.get(&field.name)) {
2270 Some(v) if !v.is_null() => v,
2271 _ => continue,
2273 };
2274 write_synx_entry(out, &field.name, value, 2, 0);
2275 }
2276 }
2277 Ok(())
2278}
2279
2280fn write_field_decl(out: &mut String, field: &FieldDecl, block: bool) {
2281 out.push_str(&field.name);
2282 if !block {
2285 if let Some(ref hint) = field.type_hint {
2286 out.push('(');
2287 out.push_str(hint);
2288 out.push(')');
2289 }
2290 }
2291
2292 let c = &field.constraints;
2293 let mut parts: Vec<String> = Vec::new();
2294 if !block {
2295 if let Some(ref t) = c.type_name {
2296 parts.push(format!("type:{}", t));
2297 }
2298 }
2299 if c.required {
2300 parts.push("required".to_string());
2301 }
2302 if c.readonly {
2303 parts.push("readonly".to_string());
2304 }
2305 if let Some(min) = c.min {
2306 parts.push(format!("min:{}", trim_float(min)));
2307 }
2308 if let Some(max) = c.max {
2309 parts.push(format!("max:{}", trim_float(max)));
2310 }
2311 if let Some(ref p) = c.pattern {
2312 parts.push(format!("pattern:{}", p));
2313 }
2314 if let Some(ref e) = c.enum_values {
2315 parts.push(format!("enum:{}", e.join("|")));
2316 }
2317 if block {
2318 parts.push("block".to_string());
2319 }
2320 if !parts.is_empty() {
2321 out.push('[');
2322 out.push_str(&parts.join(", "));
2323 out.push(']');
2324 }
2325}
2326
2327fn needs_block(value: &Value) -> bool {
2329 match value {
2330 Value::Object(_) | Value::Array(_) => true,
2332 Value::String(s) | Value::Secret(s) => {
2333 s.contains('\n')
2334 || s.contains('\r')
2335 || (inline_needs_quote(s) && pick_quote(s).is_none())
2339 }
2340 _ => false,
2341 }
2342}
2343
2344fn inline_needs_quote(s: &str) -> bool {
2346 s.is_empty()
2347 || s != trim_wsp(s)
2348 || s.contains(';')
2349 || s.starts_with('#')
2350 || s.starts_with("//")
2351 || s.starts_with('!')
2352 || s.starts_with('"')
2354 || s.starts_with('\'')
2355 || cast_inline(s) != Value::String(s.to_string())
2356}
2357
2358fn pick_quote(s: &str) -> Option<char> {
2360 if !s.contains('"') {
2361 Some('"')
2362 } else if !s.contains('\'') {
2363 Some('\'')
2364 } else {
2365 None
2366 }
2367}
2368
2369fn write_inline(out: &mut String, value: &Value) {
2371 match value {
2372 Value::Null => {}
2374 Value::Bool(b) => out.push_str(if *b { "true" } else { "false" }),
2375 Value::Int(n) => {
2376 let mut buf = itoa::Buffer::new();
2377 out.push_str(buf.format(*n));
2378 }
2379 Value::Float(f) => write_float(out, *f),
2380 Value::String(s) | Value::Secret(s) => {
2381 if inline_needs_quote(s) {
2382 match pick_quote(s) {
2383 Some(q) => {
2384 out.push(q);
2385 out.push_str(s);
2386 out.push(q);
2387 }
2388 None => out.push_str(s),
2391 }
2392 } else {
2393 out.push_str(s);
2394 }
2395 }
2396 Value::Object(_) | Value::Array(_) => {}
2398 }
2399}
2400
2401fn write_float(out: &mut String, f: f64) {
2408 if !f.is_finite() {
2409 return;
2410 }
2411 let s = f.to_string();
2412 if !s.contains('e') && !s.contains('E') {
2413 out.push_str(&s);
2414 if !s.contains('.') {
2415 out.push_str(".0");
2416 }
2417 return;
2418 }
2419 let expanded = format!("{:.*}", FLOAT_EXPANSION_PRECISION, f);
2422 let trimmed = expanded.trim_end_matches('0');
2423 let trimmed = if trimmed.ends_with('.') { &expanded[..trimmed.len() + 1] } else { trimmed };
2424 out.push_str(trimmed);
2425}
2426
2427fn trim_float(f: f64) -> String {
2428 let s = f.to_string();
2429 s.strip_suffix(".0").map(|s| s.to_string()).unwrap_or(s)
2430}
2431
2432fn write_synx_entry(out: &mut String, key: &str, value: &Value, indent: usize, depth: usize) {
2436 if depth > MAX_WRITE_DEPTH {
2437 return;
2438 }
2439 match value {
2440 Value::Object(map) => {
2441 push_indent(out, indent);
2442 out.push_str(key);
2443 out.push('\n');
2444 let mut keys: Vec<&String> = map.keys().collect();
2445 keys.sort_unstable();
2446 for k in keys {
2447 write_synx_entry(out, k, &map[k], indent + 2, depth + 1);
2448 }
2449 }
2450 Value::Array(items) => {
2451 push_indent(out, indent);
2452 out.push_str(key);
2453 if items.is_empty() {
2454 out.push_str(":join");
2457 }
2458 out.push('\n');
2459 for item in items {
2460 write_synx_item(out, item, indent + 2, depth + 1);
2461 }
2462 }
2463 Value::String(s) if s.contains('\n') => write_synx_multiline(out, key, s, indent),
2464 _ => {
2465 let scalar = synx_scalar(value);
2466 match scalar {
2467 Some(text) => {
2468 push_indent(out, indent);
2469 out.push_str(key);
2470 out.push(' ');
2471 out.push_str(&text);
2472 out.push('\n');
2473 }
2474 None => {
2478 let s = value.as_str().unwrap_or_default().to_string();
2479 write_synx_multiline(out, key, &s, indent);
2480 }
2481 }
2482 }
2483 }
2484}
2485
2486fn write_synx_item(out: &mut String, item: &Value, indent: usize, depth: usize) {
2488 if depth > MAX_WRITE_DEPTH {
2489 return;
2490 }
2491 match item {
2492 Value::Object(map) => {
2493 let mut keys: Vec<&String> = map.keys().collect();
2494 keys.sort_unstable();
2495 let head = keys
2498 .iter()
2499 .position(|k| dash_line_safe(&map[*k]))
2500 .unwrap_or(0);
2501 let head_key = keys[head];
2502 push_indent(out, indent);
2503 out.push_str("- ");
2504 out.push_str(head_key);
2505 match synx_scalar(&map[head_key]) {
2506 Some(text) if !text.is_empty() => {
2507 out.push(' ');
2508 out.push_str(&text);
2509 }
2510 _ => {}
2513 }
2514 out.push('\n');
2515 for (i, k) in keys.iter().enumerate() {
2516 if i == head {
2517 continue;
2518 }
2519 write_synx_entry(out, k, &map[*k], indent + 2, depth + 1);
2520 }
2521 }
2522 _ => {
2523 push_indent(out, indent);
2524 out.push_str("- ");
2525 match synx_scalar(item) {
2526 Some(text) => out.push_str(&text),
2527 None => out.push_str(item.as_str().unwrap_or_default()),
2528 }
2529 out.push('\n');
2530 }
2531 }
2532}
2533
2534fn dash_line_safe(value: &Value) -> bool {
2536 match value {
2537 Value::Object(map) => map.is_empty(),
2538 Value::Array(_) => false,
2539 Value::String(s) => !s.contains('\n') && synx_scalar(value).is_some() && !s.is_empty(),
2540 _ => true,
2541 }
2542}
2543
2544fn write_synx_multiline(out: &mut String, key: &str, body: &str, indent: usize) {
2546 push_indent(out, indent);
2547 out.push_str(key);
2548 out.push_str(" |+\n");
2549 for line in body.split('\n') {
2550 push_indent(out, indent + 2);
2551 out.push_str(line);
2552 out.push('\n');
2553 }
2554}
2555
2556fn synx_scalar(value: &Value) -> Option<String> {
2561 match value {
2562 Value::Null => Some("null".to_string()),
2563 Value::Bool(b) => Some(if *b { "true" } else { "false" }.to_string()),
2564 Value::Int(n) => Some(n.to_string()),
2565 Value::Float(f) => {
2566 if !f.is_finite() {
2569 return Some("null".to_string());
2570 }
2571 let mut s = String::new();
2572 write_float(&mut s, *f);
2573 Some(s)
2574 }
2575 Value::String(s) | Value::Secret(s) => {
2576 if s.contains('\n') || s.contains(" #") || s.contains(" //") {
2577 return None;
2578 }
2579 if !synx_value_needs_quote(s) {
2580 return Some(s.clone());
2581 }
2582 pick_quote(s).map(|q| format!("{}{}{}", q, s, q))
2583 }
2584 Value::Object(_) | Value::Array(_) => None,
2585 }
2586}
2587
2588fn synx_value_needs_quote(s: &str) -> bool {
2590 s.is_empty()
2591 || s != s.trim()
2592 || s == "|"
2593 || s == "|+"
2594 || is_quote_wrapped(s)
2595 || !matches!(parser::cast(s), Value::String(ref v) if v == s)
2596}
2597
2598#[inline]
2601fn starts_with_wsp(s: &str) -> bool {
2602 s.starts_with(' ') || s.starts_with('\t')
2603}
2604
2605#[inline]
2606fn trim_wsp(s: &str) -> &str {
2607 s.trim_matches(|c| c == ' ' || c == '\t')
2608}
2609
2610fn is_quote_wrapped(s: &str) -> bool {
2612 let b = s.as_bytes();
2613 b.len() >= 2
2614 && ((b[0] == b'"' && b[b.len() - 1] == b'"') || (b[0] == b'\'' && b[b.len() - 1] == b'\''))
2615}
2616
2617fn push_indent(out: &mut String, n: usize) {
2618 for _ in 0..n {
2619 out.push(' ');
2620 }
2621}
2622
2623fn truncate_utf8(s: &str, max: usize) -> &str {
2625 if s.len() <= max {
2626 return s;
2627 }
2628 let mut end = max;
2629 while end > 0 && !s.is_char_boundary(end) {
2630 end -= 1;
2631 }
2632 &s[..end]
2633}
2634
2635fn elide(s: &str) -> String {
2637 const MAX: usize = 48;
2638 if s.len() <= MAX {
2639 return s.to_string();
2640 }
2641 let cut = truncate_utf8(s, MAX);
2642 format!("{}…", cut)
2643}
2644
2645#[cfg(test)]
2646mod tests {
2647 use super::*;
2648
2649 fn doc(src: &str) -> SynxlDocument {
2650 parse_lines(src).expect("expected a successful parse")
2651 }
2652
2653 fn kinds(d: &SynxlDocument) -> Vec<DiagnosticKind> {
2654 d.diagnostics.iter().map(|x| x.kind).collect()
2655 }
2656
2657 #[test]
2660 fn parses_prologue_and_simple_records() {
2661 let d = doc("!synxl 1\n!fields id[type:int] ; name\n1 ; Wario\n2 ; Mario\n");
2662 assert_eq!(d.version, 1);
2663 assert_eq!(d.len(), 2);
2664 assert_eq!(d.to_json(), r#"[{"id":1,"name":"Wario"},{"id":2,"name":"Mario"}]"#);
2665 assert!(d.diagnostics.is_empty());
2666 }
2667
2668 #[test]
2669 fn bom_and_crlf_are_tolerated() {
2670 let d = doc("\u{feff}!synxl 1\r\n!fields a ; b\r\n1 ; 2\r\n");
2671 assert_eq!(d.to_json(), r#"[{"a":1,"b":2}]"#);
2672 }
2673
2674 #[test]
2675 fn comments_and_blank_lines_before_prologue() {
2676 let d = doc("# hi\n\n// there\n###\n!synxl 999\n###\n!synxl 1\n!fields a\nx\n");
2677 assert_eq!(d.to_json(), r#"[{"a":"x"}]"#);
2678 }
2679
2680 #[test]
2681 fn missing_prologue_is_a_hard_error() {
2682 let e = parse_lines("!fields a\nx\n").unwrap_err();
2683 assert_eq!(e.kind, SynxlErrorKind::MissingPrologue);
2684 let e = parse_lines("").unwrap_err();
2685 assert_eq!(e.kind, SynxlErrorKind::MissingPrologue);
2686 let e = parse_lines("!synxl\n!fields a\n").unwrap_err();
2687 assert_eq!(e.kind, SynxlErrorKind::MissingPrologue);
2688 let e = parse_lines("!synxl 1 extra\n!fields a\n").unwrap_err();
2690 assert_eq!(e.kind, SynxlErrorKind::MissingPrologue);
2691 }
2692
2693 #[test]
2694 fn unsupported_version_is_a_hard_error() {
2695 let e = parse_lines("!synxl 2\n!fields a\nx\n").unwrap_err();
2696 assert_eq!(e.kind, SynxlErrorKind::UnsupportedVersion);
2697 let e = parse_lines("!synxl 99999999999999999999\n!fields a\n").unwrap_err();
2698 assert_eq!(e.kind, SynxlErrorKind::UnsupportedVersion);
2699 }
2700
2701 #[test]
2702 fn record_without_field_list_is_a_hard_error() {
2703 let e = parse_lines("!synxl 1\nrogue record\n").unwrap_err();
2704 assert_eq!(e.kind, SynxlErrorKind::NoFieldList);
2705 assert_eq!(e.line, 2);
2706 }
2707
2708 #[test]
2709 fn field_list_can_be_redeclared_mid_document() {
2710 let src = "!synxl 1\n\
2712 !fields id[type:int] ; score[type:float]\n\
2713 1 ; 0.91\n\
2714 # schema evolution\n\
2715 !fields id[type:int] ; score[type:float] ; lang\n\
2716 3 ; 0.55 ; ru\n";
2717 let d = doc(src);
2718 assert_eq!(
2719 d.to_json(),
2720 r#"[{"id":1,"score":0.91},{"id":3,"lang":"ru","score":0.55}]"#
2721 );
2722 assert_eq!(d.field_lists.len(), 2);
2723 assert_eq!(d.record_field_lists, vec![0, 1]);
2724 assert_eq!(d.field_list_for(1).unwrap().arity(), 3);
2725 }
2726
2727 #[test]
2730 fn duplicate_field_name_is_a_hard_error() {
2731 let e = parse_lines("!synxl 1\n!fields a ; b ; a\nx ; y ; z\n").unwrap_err();
2732 assert_eq!(e.kind, SynxlErrorKind::DuplicateField);
2733 }
2734
2735 #[test]
2736 fn marker_chain_is_a_hard_error() {
2737 let e = parse_lines("!synxl 1\n!fields port:env:default:3000\n1\n").unwrap_err();
2738 assert_eq!(e.kind, SynxlErrorKind::MarkerChain);
2739 let e = parse_lines("!synxl 1\n!fields id[required]:env\n1\n").unwrap_err();
2740 assert_eq!(e.kind, SynxlErrorKind::MarkerChain);
2741 let e = parse_lines("!synxl 1\n!fields id:custom\n1\n").unwrap_err();
2743 assert_eq!(e.kind, SynxlErrorKind::MarkerChain);
2744 }
2745
2746 #[test]
2747 fn non_deterministic_hint_is_a_hard_error() {
2748 for src in [
2749 "!synxl 1\n!fields id(random)\n1\n",
2750 "!synxl 1\n!fields id(random:float)\n1\n",
2751 "!synxl 1\n!fields id[type:random:bool]\n1\n",
2752 ] {
2753 let e = parse_lines(src).unwrap_err();
2754 assert_eq!(e.kind, SynxlErrorKind::NonDeterministicHint, "{src}");
2755 }
2756 }
2757
2758 #[test]
2759 fn block_with_type_is_a_hard_error() {
2760 let e = parse_lines("!synxl 1\n!fields m(int)[block]\n\n").unwrap_err();
2761 assert_eq!(e.kind, SynxlErrorKind::BlockWithType);
2762 let e = parse_lines("!synxl 1\n!fields m[block, type:int]\n\n").unwrap_err();
2763 assert_eq!(e.kind, SynxlErrorKind::BlockWithType);
2764 }
2765
2766 #[test]
2767 fn malformed_field_lists_are_hard_errors() {
2768 for src in [
2769 "!synxl 1\n!fields\nx\n",
2770 "!synxl 1\n!fields a ; ; b\nx\n",
2771 "!synxl 1\n!fields a[required\nx\n",
2772 "!synxl 1\n!fields a(int\nx\n",
2773 ] {
2774 let e = parse_lines(src).unwrap_err();
2775 assert_eq!(e.kind, SynxlErrorKind::MalformedFieldList, "{src}");
2776 }
2777 }
2778
2779 #[test]
2780 fn field_name_length_limit() {
2781 let long = "n".repeat(MAX_SYNXL_FIELD_NAME_BYTES + 1);
2782 let e = parse_lines(&format!("!synxl 1\n!fields {long}\nx\n")).unwrap_err();
2783 assert_eq!(e.kind, SynxlErrorKind::LimitExceeded);
2784 }
2785
2786 #[test]
2787 fn field_count_limit() {
2788 let mut src = String::from("!synxl 1\n!fields ");
2789 for i in 0..(MAX_SYNXL_FIELDS + 1) {
2790 if i > 0 {
2791 src.push_str(" ; ");
2792 }
2793 src.push_str(&format!("f{i}"));
2794 }
2795 src.push('\n');
2796 let e = parse_lines(&src).unwrap_err();
2797 assert_eq!(e.kind, SynxlErrorKind::LimitExceeded);
2798 }
2799
2800 #[test]
2801 fn unrecognised_constraint_parts_are_tolerated() {
2802 let d = doc("!synxl 1\n!fields a[required, futureflag, future:thing]\n5\n");
2804 assert_eq!(d.to_json(), r#"[{"a":5}]"#);
2805 assert!(d.field_lists[0].get("a").unwrap().constraints.required);
2806 }
2807
2808 #[test]
2809 fn arity_ignores_block_fields() {
2810 let d = doc("!synxl 1\n!fields a ; b ; m[block]\n1 ; 2\n");
2811 assert_eq!(d.field_lists[0].arity(), 2);
2812 assert!(d.diagnostics.is_empty());
2813 assert_eq!(d.to_json(), r#"[{"a":1,"b":2,"m":null}]"#);
2814 }
2815
2816 #[test]
2819 fn synx_first_character_filter_does_not_apply() {
2820 let src = "!synxl 1\n!fields v\n-5\n/var/log/app\n@kaiserberg\n[unparsed]\n:marker\n(paren)\n";
2821 let d = doc(src);
2822 assert_eq!(
2823 d.to_json(),
2824 r#"[{"v":-5},{"v":"/var/log/app"},{"v":"@kaiserberg"},{"v":"[unparsed]"},{"v":":marker"},{"v":"(paren)"}]"#
2825 );
2826 }
2827
2828 #[test]
2829 fn reserved_prefixes_at_indent_zero() {
2830 let d = doc("!synxl 1\n!fields v\n//comment\n/data\n# comment\n\"!bang\"\n'#quoted'\n");
2833 assert_eq!(d.to_json(), r##"[{"v":"/data"},{"v":"!bang"},{"v":"#quoted"}]"##);
2834 }
2835
2836 #[test]
2837 fn unknown_bang_line_is_a_hard_error() {
2838 for src in [
2841 "!synxl 1\n!fields a\n1\n!filds a ; b\n2 ; 3\n",
2842 "!synxl 1\n!active\n!fields a\n1\n",
2843 "!synxl 1\n!fields a\n!include /etc/passwd\n1\n",
2844 "!synxl 1\n!fieldsx a\n1\n",
2845 ] {
2846 let e = parse_lines(src).unwrap_err();
2847 assert_eq!(e.kind, SynxlErrorKind::UnknownDirective, "{src}");
2848 }
2849 }
2850
2851 #[test]
2852 fn repeated_prologue() {
2853 let d = doc("!synxl 1\n!fields a\n1\n!synxl 1\n!fields a\n2\n");
2855 assert_eq!(d.to_json(), r#"[{"a":1},{"a":2}]"#);
2856 let e = parse_lines("!synxl 1\n!fields a\n1\n!synxl 2\n!fields a\n2\n").unwrap_err();
2857 assert_eq!(e.kind, SynxlErrorKind::UnsupportedVersion);
2858 assert_eq!(e.line, 4);
2859 }
2860
2861 #[test]
2862 fn zero_arity_field_list_is_a_hard_error() {
2863 let e = parse_lines("!synxl 1\n!fields m[block]\n\n m\n k v\n").unwrap_err();
2865 assert_eq!(e.kind, SynxlErrorKind::ZeroArity);
2866 let e = parse_lines("!synxl 1\n!fields a[block] ; b[block]\n").unwrap_err();
2867 assert_eq!(e.kind, SynxlErrorKind::ZeroArity);
2868 }
2869
2870 #[test]
2873 fn quoting_and_the_unquoted_fallback() {
2874 let d = doc(
2875 "!synxl 1\n!fields a ; b ; c\n\
2876 \"has ; semi\" ; 'single' ; \"a\"b\" \n",
2877 );
2878 let rec = d.records[0].as_object().unwrap();
2879 assert_eq!(rec["a"], Value::String("has ; semi".into()));
2880 assert_eq!(rec["b"], Value::String("single".into()));
2881 assert_eq!(rec["c"], Value::String("\"a\"b\"".into()));
2884 }
2885
2886 #[test]
2887 fn unterminated_quote_falls_back_to_unquoted() {
2888 let d = doc("!synxl 1\n!fields a ; b\n\"open ; still\n");
2889 let rec = d.records[0].as_object().unwrap();
2890 assert_eq!(rec["a"], Value::String("\"open".into()));
2891 assert_eq!(rec["b"], Value::String("still".into()));
2892 }
2893
2894 #[test]
2895 fn quoted_values_bypass_casting() {
2896 let d = doc("!synxl 1\n!fields a ; b ; c\n\"42\" ; \"true\" ; \"null\"\n");
2897 assert_eq!(d.to_json(), r#"[{"a":"42","b":"true","c":"null"}]"#);
2898 }
2899
2900 #[test]
2901 fn null_versus_empty_string() {
2902 let d = doc("!synxl 1\n!fields a ; b ; c\n; \"\" ; x\n");
2905 assert_eq!(d.to_json(), r#"[{"a":null,"b":"","c":"x"}]"#);
2906 }
2907
2908 #[test]
2909 fn quoted_part_keeps_interior_whitespace() {
2910 let d = doc("!synxl 1\n!fields a ; b\n\" padded \" ; bare \n");
2911 let rec = d.records[0].as_object().unwrap();
2912 assert_eq!(rec["a"], Value::String(" padded ".into()));
2913 assert_eq!(rec["b"], Value::String("bare".into()));
2914 }
2915
2916 #[test]
2917 fn only_spaces_and_tabs_are_trimmed_from_a_part() {
2918 let d = doc("!synxl 1\n!fields a ; b\nx ; \u{a0}hello\u{a0}\n");
2923 assert_eq!(
2924 d.records[0].as_object().unwrap()["b"],
2925 Value::String("\u{a0}hello\u{a0}".into())
2926 );
2927 let d = doc("!synxl 1\n!fields a\n\u{a0}hello\n");
2929 assert_eq!(d.to_json(), "[]");
2930 assert_eq!(kinds(&d), vec![DiagnosticKind::OrphanBlockLine]);
2931 }
2932
2933 #[test]
2934 fn arity_mismatch_in_both_directions() {
2935 let d = doc("!synxl 1\n!fields a ; b ; c\n1 ; 2\n1 ; 2 ; 3 ; 4 ; 5\n");
2936 assert_eq!(kinds(&d), vec![DiagnosticKind::MissingFields, DiagnosticKind::ExtraFields]);
2937 assert_eq!(d.diagnostics[0].record_index, 0);
2938 assert_eq!(d.diagnostics[0].line, 3);
2939 assert_eq!(d.diagnostics[1].record_index, 1);
2940 assert_eq!(d.diagnostics[1].line, 4);
2941 assert_eq!(d.to_json(), r#"[{"a":1,"b":2,"c":null},{"a":1,"b":2,"c":3}]"#);
2942 }
2943
2944 #[test]
2945 fn all_null_record() {
2946 let d = doc("!synxl 1\n!fields a\n;\n");
2948 assert_eq!(d.to_json(), r#"[{"a":null}]"#);
2949 assert!(d.diagnostics.is_empty());
2950
2951 let d = doc("!synxl 1\n!fields a ; b\n;\n");
2952 assert_eq!(d.to_json(), r#"[{"a":null,"b":null}]"#);
2953 assert!(d.diagnostics.is_empty());
2954
2955 let d = doc("!synxl 1\n!fields a ; b ; c\n;\t\n");
2957 assert_eq!(d.to_json(), r#"[{"a":null,"b":null,"c":null}]"#);
2958 assert!(d.diagnostics.is_empty());
2959
2960 let d = doc("!synxl 1\n!fields a ; m[block]\n;\n m\n k v\n");
2962 assert_eq!(d.to_json(), r#"[{"a":null,"m":{"k":"v"}}]"#);
2963 assert!(d.diagnostics.is_empty());
2964
2965 let d = doc("!synxl 1\n!fields a ; b\n;;\n");
2968 assert_eq!(d.to_json(), r#"[{"a":null,"b":null}]"#);
2969 assert_eq!(kinds(&d), vec![DiagnosticKind::ExtraFields]);
2970 }
2971
2972 #[test]
2973 fn all_null_record_round_trips_at_every_arity() {
2974 for fields in ["a", "a ; b", "a ; b ; c", "a ; b ; m[block]"] {
2977 let src = format!("!synxl 1\n!fields {fields}\n;\n");
2978 let a = doc(&src);
2979 let written = a.to_synxl().unwrap();
2980 assert!(
2981 written.lines().any(|l| l == ";"),
2982 "expected an all-null record line for `{fields}`:\n{written}"
2983 );
2984 assert_eq!(a.to_json(), doc(&written).to_json(), "{written}");
2985 assert!(doc(&written).diagnostics.is_empty(), "{written}");
2986 }
2987 }
2988
2989 #[test]
2990 fn trailing_semicolon_yields_a_part() {
2991 let d = doc("!synxl 1\n!fields a ; b\n1 ;\n");
2992 assert_eq!(d.to_json(), r#"[{"a":1,"b":null}]"#);
2993 assert!(d.diagnostics.is_empty());
2994 }
2995
2996 #[test]
2997 fn inline_comments_are_not_stripped() {
2998 let d = doc("!synxl 1\n!fields text\nsee https://x.dev #hashtag // not a comment\n");
3000 assert_eq!(
3001 d.records[0].as_object().unwrap()["text"],
3002 Value::String("see https://x.dev #hashtag // not a comment".into())
3003 );
3004 }
3005
3006 #[test]
3009 fn automatic_casting() {
3010 let d = doc("!synxl 1\n!fields a ; b ; c ; d ; e ; f\n1 ; -2.5 ; true ; false ; null ; text\n");
3011 assert_eq!(
3012 d.to_json(),
3013 r#"[{"a":1,"b":-2.5,"c":true,"d":false,"e":null,"f":"text"}]"#
3014 );
3015 }
3016
3017 #[test]
3018 fn typed_cast_failure_nulls_the_cell_only() {
3019 let d = doc("!synxl 1\n!fields id[type:int] ; score(float)\nnope ; 0.5\n7 ; nope\n");
3020 assert_eq!(kinds(&d), vec![DiagnosticKind::CastFailed, DiagnosticKind::CastFailed]);
3021 assert_eq!(d.to_json(), r#"[{"id":null,"score":0.5},{"id":7,"score":null}]"#);
3022 }
3023
3024 #[test]
3025 fn typed_bool_and_string() {
3026 let d = doc("!synxl 1\n!fields a[type:bool] ; b(string) ; c[type:bool]\ntrue ; 42 ; yes\n");
3027 assert_eq!(d.to_json(), r#"[{"a":true,"b":"42","c":null}]"#);
3028 assert_eq!(kinds(&d), vec![DiagnosticKind::CastFailed]);
3029 }
3030
3031 #[test]
3032 fn validation_is_opt_in() {
3033 let src = "!synxl 1\n!fields id[type:int, min:10] ; name[required]\n5 ; \n";
3034 let d = doc(src);
3035 assert!(d.diagnostics.is_empty());
3036
3037 let d = parse_lines_with(src, &SynxlOptions { validate: true }).unwrap();
3038 assert_eq!(
3039 kinds(&d),
3040 vec![DiagnosticKind::ConstraintViolation, DiagnosticKind::ConstraintViolation]
3041 );
3042 }
3043
3044 #[test]
3047 fn worked_example_from_the_spec() {
3048 let src = "!synxl 1
3051!fields id[type:int, required] ; score[type:float] ; messages[block]
3052
30531 ; 0.91
3054 messages
3055 - role system
3056 content You are a helpful assistant.
3057 - role user
3058 content |+
3059 def f(x):
3060 return x + 1
3061
30622 ; 0.74
3063 messages
3064 - role user
3065 content Привет
3066";
3067 let d = doc(src);
3068 assert_eq!(d.len(), 2);
3069 assert!(d.diagnostics.is_empty());
3070 let mut expected = String::from(
3071 r#"{"id":1,"messages":[{"content":"You are a helpful assistant.","role":"system"},"#,
3072 );
3073 expected.push_str(r#"{"content":"def f(x):\n return x + 1","role":"user"}],"score":0.91}"#);
3074 let mut json = String::new();
3075 crate::write_json(&mut json, &d.records[0]);
3076 assert_eq!(json, expected);
3077 assert_eq!(
3078 d.to_ndjson().lines().count(),
3079 2,
3080 "NDJSON projection is one object per line"
3081 );
3082 }
3083
3084 #[test]
3085 fn empty_block_yields_null_block_fields() {
3086 let d = doc("!synxl 1\n!fields a ; m[block]\n1\n\n2\n");
3087 assert_eq!(d.to_json(), r#"[{"a":1,"m":null},{"a":2,"m":null}]"#);
3088 }
3089
3090 #[test]
3091 fn blank_lines_do_not_terminate_a_block() {
3092 let d = doc("!synxl 1\n!fields a ; m[block]\n1\n\n m\n x 1\n\n2\n");
3093 assert_eq!(d.to_json(), r#"[{"a":1,"m":{"x":1}},{"a":2,"m":null}]"#);
3094 }
3095
3096 #[test]
3097 fn block_key_diagnostics() {
3098 let d = doc("!synxl 1\n!fields a ; m[block]\n1\n bogus 1\n a 99\n");
3099 assert_eq!(
3101 kinds(&d),
3102 vec![DiagnosticKind::BlockFieldNotDeclared, DiagnosticKind::UnknownBlockKey]
3103 );
3104 assert_eq!(d.diagnostics[0].line, 5);
3107 assert_eq!(d.diagnostics[1].line, 4);
3108 assert!(d.diagnostics.iter().all(|x| x.record_index == 0));
3109 assert_eq!(d.to_json(), r#"[{"a":1,"m":null}]"#);
3111 }
3112
3113 #[test]
3114 fn block_diagnostic_line_survives_nesting_and_multiline() {
3115 let src = "!synxl 1
3116!fields id ; m[block]
31171
3118 m
3119 - role user
3120 content |+
3121 text
3122 zzz here
3123";
3124 let d = doc(src);
3125 assert_eq!(kinds(&d), vec![DiagnosticKind::UnknownBlockKey]);
3126 assert_eq!(d.diagnostics[0].line, 8);
3128 }
3129
3130 #[test]
3131 fn type_hints_inside_a_cell_are_not_interpreted() {
3132 let d = doc("!synxl 1\n!fields a ; b\n(random) ; (int)5\n");
3134 assert_eq!(d.to_json(), r#"[{"a":"(random)","b":"(int)5"}]"#);
3135 }
3136
3137 #[test]
3138 fn directives_inside_a_block_are_ignored() {
3139 let src = "!synxl 1\n!fields a ; m[block]\n1\n m\n !include /etc/passwd\n k v\n";
3141 let d = doc(src);
3142 assert_eq!(d.to_json(), r#"[{"a":1,"m":{"k":"v"}}]"#);
3143 }
3144
3145 #[test]
3146 fn directives_inside_a_multiline_body_are_preserved() {
3147 let src = "!synxl 1\n!fields a ; m[block]\n1\n m |+\n !include /etc/passwd\n !active\n";
3149 let d = doc(src);
3150 assert_eq!(
3151 d.records[0].as_object().unwrap()["m"],
3152 Value::String("!include /etc/passwd\n!active".into())
3153 );
3154 }
3155
3156 #[test]
3157 fn active_mode_cannot_be_switched_on_from_a_block() {
3158 let src = "!synxl 1\n!fields a ; m[block]\n1\n !active\n m\n tax:calc 2 * 2\n";
3160 let d = doc(src);
3161 assert_eq!(d.to_json(), r#"[{"a":1,"m":{"tax":"2 * 2"}}]"#);
3162 }
3163
3164 #[test]
3165 fn orphan_block_lines_are_reported_against_the_next_record() {
3166 let d = doc("!synxl 1\n!fields a\n stray one\n5\n!fields a\n stray two\n");
3168 assert_eq!(d.to_json(), r#"[{"a":5}]"#);
3169 assert_eq!(
3170 kinds(&d),
3171 vec![DiagnosticKind::OrphanBlockLine, DiagnosticKind::OrphanBlockLine]
3172 );
3173 assert_eq!((d.diagnostics[0].record_index, d.diagnostics[0].line), (0, 3));
3176 assert_eq!((d.diagnostics[1].record_index, d.diagnostics[1].line), (1, 6));
3177 }
3178
3179 #[test]
3182 fn oversized_record_is_truncated_not_rejected() {
3183 let mut src = String::from("!synxl 1\n!fields a ; b\n");
3184 src.push_str("head ; ");
3185 src.push_str(&"x".repeat(MAX_SYNXL_RECORD_BYTES + 16));
3186 src.push_str("\ntail ; ok\n");
3187 let d = doc(&src);
3188 assert_eq!(d.len(), 2, "the following record still parses");
3189 assert_eq!(kinds(&d), vec![DiagnosticKind::RecordTruncated]);
3190 assert_eq!(d.diagnostics[0].record_index, 0);
3191 let b = d.records[0].as_object().unwrap()["b"].as_str().unwrap();
3192 assert!(b.len() < MAX_SYNXL_RECORD_BYTES);
3193 assert_eq!(d.records[1].as_object().unwrap()["a"], Value::String("tail".into()));
3194 }
3195
3196 #[test]
3197 fn field_list_count_limit() {
3198 let mut src = String::from("!synxl 1\n");
3199 for _ in 0..(MAX_SYNXL_FIELD_LISTS + 1) {
3200 src.push_str("!fields a\n");
3201 }
3202 let e = parse_lines(&src).unwrap_err();
3203 assert_eq!(e.kind, SynxlErrorKind::LimitExceeded);
3204 }
3205
3206 #[test]
3207 fn truncation_respects_utf8_boundaries() {
3208 let s = "aя";
3210 assert_eq!(truncate_utf8(s, 2), "a");
3211 assert_eq!(truncate_utf8(s, 3), "aя");
3212 assert_eq!(truncate_utf8(s, 99), "aя");
3213 assert_eq!(truncate_utf8("😀", 3), "");
3214 }
3215
3216 #[test]
3217 fn streaming_and_whole_document_agree() {
3218 let src = "!synxl 1
3219!fields id[type:int] ; note ; m[block]
32201 ; ok
3221 m
3222 k v
32232 ; oops ; surplus
3224!fields id[type:int] ; note
32253
3226";
3227 let whole = doc(src);
3228 let mut records = Vec::new();
3229 let mut diagnostics = Vec::new();
3230 for item in SynxlReader::new(src).unwrap() {
3231 let mut rec = item.unwrap();
3232 records.push(rec.value);
3233 diagnostics.append(&mut rec.diagnostics);
3234 }
3235 assert_eq!(whole.to_json(), records_to_json_array(&records));
3236 assert_eq!(whole.diagnostics, diagnostics);
3237 assert_eq!(
3238 kinds(&whole),
3239 vec![DiagnosticKind::ExtraFields, DiagnosticKind::MissingFields]
3240 );
3241 }
3242
3243 #[test]
3244 fn a_document_may_exceed_the_synx_input_cap() {
3245 let mut src = String::from("!synxl 1\n!fields a ; b\n");
3247 let row = "0123456789 ; abcdefghij\n";
3248 let rows = (17 * 1024 * 1024) / row.len() + 1;
3249 src.reserve(rows * row.len());
3250 for _ in 0..rows {
3251 src.push_str(row);
3252 }
3253 assert!(src.len() > 16 * 1024 * 1024);
3254 let d = doc(&src);
3255 assert_eq!(d.len(), rows);
3256 assert!(d.diagnostics.is_empty());
3257 }
3258
3259 #[test]
3262 fn streaming_reader_yields_records_incrementally() {
3263 let src = "!synxl 1\n!fields a ; m[block]\n1\n m\n k v\n2 ; ignored\n";
3264 let mut reader = SynxlReader::new(src).unwrap();
3265 let first = reader.next().unwrap().unwrap();
3266 assert_eq!(first.index, 0);
3267 assert_eq!(first.line, 3);
3268 assert!(first.diagnostics.is_empty());
3269 let second = reader.next().unwrap().unwrap();
3270 assert_eq!(second.index, 1);
3271 assert_eq!(second.line, 6);
3272 assert_eq!(second.diagnostics.len(), 1);
3273 assert_eq!(second.diagnostics[0].kind, DiagnosticKind::ExtraFields);
3274 assert!(reader.next().is_none());
3275 }
3276
3277 #[test]
3278 fn owned_reader_matches_the_borrowing_one() {
3279 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";
3280
3281 let borrowed: Vec<SynxlRecord> = SynxlReader::new(src)
3282 .unwrap()
3283 .map(Result::unwrap)
3284 .collect();
3285
3286 let mut owned = {
3289 let moved = String::from(src);
3290 SynxlReaderOwned::new(moved).unwrap()
3291 };
3292 assert_eq!(owned.version(), 1);
3293 let mut collected = Vec::new();
3294 while let Some(item) = owned.next() {
3295 collected.push(item.unwrap());
3296 }
3297
3298 assert_eq!(collected, borrowed);
3299 assert_eq!(collected.len(), 2);
3300 assert_eq!(collected[1].line, 6, "BOM and CRLF must not shift line numbers");
3301 assert_eq!(owned.field_lists().len(), 1);
3302 assert_eq!(owned.text(), src);
3303 }
3304
3305 #[test]
3306 fn owned_reader_is_returnable_from_a_function() {
3307 fn open(src: &str) -> SynxlReaderOwned {
3308 SynxlReaderOwned::with_options(src.to_string(), SynxlOptions { validate: true })
3309 .unwrap()
3310 }
3311 let mut reader = open("!synxl 1\n!fields a[required]\n;\n");
3312 let rec = reader.next().unwrap().unwrap();
3313 assert_eq!(rec.diagnostics.len(), 1);
3314 assert_eq!(rec.diagnostics[0].kind, DiagnosticKind::ConstraintViolation);
3315 assert!(reader.next().is_none());
3316 assert_eq!(reader.into_text().lines().count(), 3);
3317 }
3318
3319 #[test]
3320 fn owned_reader_reports_the_prologue_error_eagerly() {
3321 let err = SynxlReaderOwned::new("nope\n".to_string()).unwrap_err();
3322 assert_eq!(err.kind, SynxlErrorKind::MissingPrologue);
3323 }
3324
3325 fn all_three_agree(src: &str) -> Vec<SynxlRecord> {
3330 let borrowed: Vec<SynxlRecord> =
3331 SynxlReader::new(src).unwrap().map(Result::unwrap).collect();
3332 let owned: Vec<SynxlRecord> = SynxlReaderOwned::new(src.to_string())
3333 .unwrap()
3334 .map(Result::unwrap)
3335 .collect();
3336 let streamed: Vec<SynxlRecord> = SynxlStreamReader::new(std::io::Cursor::new(src))
3337 .unwrap()
3338 .map(Result::unwrap)
3339 .collect();
3340 assert_eq!(owned, borrowed, "owned reader diverged");
3341 assert_eq!(streamed, borrowed, "io reader diverged");
3342 borrowed
3343 }
3344
3345 #[test]
3346 fn io_streaming_matches_the_in_memory_readers() {
3347 let recs = all_three_agree(
3348 "\u{feff}!synxl 1\r
3349!fields id[type:int] ; note ; m[block]\r
3350\r
33511 ; ok\r
3352 m\r
3353 - role user\r
3354 content |+\r
3355 line one\r
3356\r
3357 line two\r
33582 ; oops ; surplus\r
3359 bogus 1\r
3360!fields id[type:int]\r
3361 orphan\r
3362;\r
3363",
3364 );
3365 assert_eq!(recs.len(), 3);
3366 assert_eq!(recs[0].line, 4, "BOM and CRLF must not shift line numbers");
3367 assert_eq!(recs[1].diagnostics.len(), 2);
3368 assert_eq!(recs[2].value.as_object().unwrap()["id"], Value::Null);
3369 }
3370
3371 #[test]
3372 fn io_streaming_agrees_on_blocks_comments_and_diagnostics() {
3373 all_three_agree(
3374 "!synxl 1
3375###
3376!fields hidden
3377###
3378!fields a ; b ; m[block]
3379# comment
33801 ; 2
3381 m
3382 k v
3383
3384 j w
3385//comment
33863 ; 4 ; 5
3387 m
3388 !include /etc/passwd
3389 q |+
3390 !active
3391;
3392",
3393 );
3394 }
3395
3396 #[test]
3397 fn io_streaming_bounds_memory_on_an_oversized_record() {
3398 let mut src = String::from("!synxl 1\n!fields a ; b\n");
3401 src.push_str("head ; ");
3402 src.push_str(&"x".repeat(MAX_SYNXL_RECORD_BYTES + 4096));
3403 src.push_str("\ntail ; ok\n");
3404
3405 let streamed: Vec<SynxlRecord> = SynxlStreamReader::new(std::io::Cursor::new(&src))
3406 .unwrap()
3407 .map(Result::unwrap)
3408 .collect();
3409 let borrowed: Vec<SynxlRecord> =
3410 SynxlReader::new(&src).unwrap().map(Result::unwrap).collect();
3411
3412 assert_eq!(streamed.len(), 2, "the record after the oversized one survives");
3413 assert_eq!(streamed[0].diagnostics[0].kind, DiagnosticKind::RecordTruncated);
3414 assert_eq!(
3415 streamed[0].value.as_object().unwrap()["b"],
3416 borrowed[0].value.as_object().unwrap()["b"],
3417 "both readers must cut at the same byte"
3418 );
3419 assert_eq!(streamed[1].value, borrowed[1].value);
3420 }
3421
3422 #[test]
3423 fn io_streaming_bounds_memory_on_an_oversized_block() {
3424 let mut src = String::from("!synxl 1\n!fields a ; m[block]\n1\n m |+\n");
3425 let line = format!(" {}\n", "y".repeat(4095));
3426 for _ in 0..((MAX_SYNXL_RECORD_BYTES / line.len()) + 2) {
3427 src.push_str(&line);
3428 }
3429 src.push_str("2\n");
3430
3431 let streamed: Vec<SynxlRecord> = SynxlStreamReader::new(std::io::Cursor::new(&src))
3432 .unwrap()
3433 .map(Result::unwrap)
3434 .collect();
3435 let borrowed: Vec<SynxlRecord> =
3436 SynxlReader::new(&src).unwrap().map(Result::unwrap).collect();
3437
3438 assert_eq!(streamed.len(), 2);
3439 assert_eq!(streamed[0].diagnostics[0].kind, DiagnosticKind::RecordTruncated);
3440 assert_eq!(streamed[0].value, borrowed[0].value);
3441 assert_eq!(streamed[1].value, borrowed[1].value);
3442 }
3443
3444 #[test]
3445 fn io_streaming_survives_a_line_without_a_newline() {
3446 let mut src = String::from("!synxl 1\n!fields a\n");
3449 src.push_str(&"z".repeat(MAX_SYNXL_RECORD_BYTES + 1024));
3450 let recs: Vec<SynxlRecord> = SynxlStreamReader::new(std::io::Cursor::new(&src))
3451 .unwrap()
3452 .map(Result::unwrap)
3453 .collect();
3454 assert_eq!(recs.len(), 1);
3455 assert_eq!(recs[0].diagnostics[0].kind, DiagnosticKind::RecordTruncated);
3456 let a = recs[0].value.as_object().unwrap()["a"].as_str().unwrap();
3457 assert_eq!(a.len(), MAX_SYNXL_RECORD_BYTES);
3458 }
3459
3460 #[test]
3461 fn io_streaming_separates_format_errors_from_io_errors() {
3462 let err = SynxlStreamReader::new(std::io::Cursor::new("nope\n")).unwrap_err();
3464 assert_eq!(err.as_format().unwrap().kind, SynxlErrorKind::MissingPrologue);
3465 assert!(err.as_io().is_none());
3466
3467 let mut reader =
3468 SynxlStreamReader::new(std::io::Cursor::new("!synxl 1\n!fields a\n1\n!filds a\n2\n"))
3469 .unwrap();
3470 assert!(reader.next().unwrap().is_ok());
3471 let err = reader.next().unwrap().unwrap_err();
3472 assert_eq!(err.as_format().unwrap().kind, SynxlErrorKind::UnknownDirective);
3473 assert!(reader.next().is_none(), "iteration stops after a hard error");
3474
3475 let bad = [b'!', b's', b'y', b'n', b'x', b'l', b' ', b'1', b'\n', 0xff, 0xfe, b'\n'];
3477 let mut reader = SynxlStreamReader::new(std::io::Cursor::new(&bad[..])).unwrap();
3478 let err = reader.next().unwrap().unwrap_err();
3479 assert!(err.as_format().is_none());
3480 assert_eq!(err.as_io().unwrap().kind(), std::io::ErrorKind::InvalidData);
3481 }
3482
3483 #[test]
3484 fn io_streaming_reports_a_failing_source() {
3485 #[derive(Debug)]
3486 struct Boom;
3487 impl std::io::Read for Boom {
3488 fn read(&mut self, _: &mut [u8]) -> std::io::Result<usize> {
3489 Err(std::io::Error::new(std::io::ErrorKind::BrokenPipe, "boom"))
3490 }
3491 }
3492 let err = SynxlStreamReader::new(std::io::BufReader::new(Boom)).unwrap_err();
3493 assert_eq!(err.as_io().unwrap().kind(), std::io::ErrorKind::BrokenPipe);
3494 }
3495
3496 #[test]
3499 fn field_list_keeps_its_source_line() {
3500 let d = doc("!synxl 1\n!fields id[type:int] ; name \n1 ; x\n!fields a\n2\n");
3502 assert_eq!(d.field_lists[0].source(), "!fields id[type:int] ; name");
3503 assert_eq!(d.field_lists[0].line, 2);
3504 assert_eq!(d.field_lists[1].source(), "!fields a");
3505 assert_eq!(d.field_lists[1].line, 4);
3506 assert_eq!(FieldList::new(vec![FieldDecl::new("a")]).source(), "");
3508
3509 let mut reader = SynxlReader::new("!synxl 1\n!fields a ; b\n1 ; 2\n").unwrap();
3512 reader.next();
3513 assert_eq!(reader.field_list().unwrap().source(), "!fields a ; b");
3514 }
3515
3516 #[test]
3517 fn streaming_reader_reports_a_mid_document_hard_error_once() {
3518 let src = "!synxl 1\n!fields a\n1\n!fields a ; a\n2\n";
3519 let mut reader = SynxlReader::new(src).unwrap();
3520 assert!(reader.next().unwrap().is_ok());
3521 let err = reader.next().unwrap().unwrap_err();
3522 assert_eq!(err.kind, SynxlErrorKind::DuplicateField);
3523 assert!(reader.next().is_none(), "iteration stops after a hard error");
3524 }
3525
3526 fn round_trip(src: &str) {
3529 let a = doc(src);
3530 let written = a.to_synxl().unwrap();
3531 let b = doc(&written);
3532 assert_eq!(
3533 a.to_json(),
3534 b.to_json(),
3535 "round-trip mismatch\n--- written ---\n{written}"
3536 );
3537 }
3538
3539 #[test]
3540 fn round_trip_scalars_and_quoting() {
3541 round_trip(
3542 "!synxl 1\n!fields a ; b ; c ; d ; e ; f ; g\n\
3543 1 ; \"42\" ; \"has ; semi\" ; ; \"\" ; -2.5 ; true\n",
3544 );
3545 }
3546
3547 #[test]
3548 fn round_trip_reserved_prefixes_and_padding() {
3549 round_trip(
3550 "!synxl 1\n!fields a ; b ; c ; d\n\
3551 \"#tag\" ; \"//path\" ; \"!bang\" ; \" padded \"\n",
3552 );
3553 }
3554
3555 #[test]
3556 fn round_trip_blocks_and_schema_evolution() {
3557 round_trip(
3558 "!synxl 1
3559!fields id[type:int, required] ; score[type:float] ; messages[block]
35601 ; 0.91
3561 messages
3562 - role system
3563 content You are a helpful assistant.
3564 - role user
3565 content |+
3566 def f(x):
3567 return x + 1
3568!fields id[type:int] ; lang ; messages[block]
35693 ; ru
3570 messages
3571 - role user
3572 content Как дела?
3573",
3574 );
3575 }
3576
3577 #[test]
3578 fn writer_promotes_multiline_values_to_a_block() {
3579 let fields = vec![FieldDecl::new("id"), FieldDecl::new("text")];
3581 let mut rec = HashMap::new();
3582 rec.insert("id".to_string(), Value::Int(1));
3583 rec.insert("text".to_string(), Value::String("line one\n line two".into()));
3584 let records = vec![Value::Object(rec)];
3585 let out = write_lines(&fields, &records).unwrap();
3586 assert!(out.contains("text[block]"), "{out}");
3587 assert!(out.contains("|+"), "{out}");
3588 let back = doc(&out);
3589 assert_eq!(back.to_json(), records_to_json_array(&records));
3590 }
3591
3592 #[test]
3593 fn writer_promotes_values_that_cannot_be_quoted() {
3594 let fields = vec![FieldDecl::new("id"), FieldDecl::new("a")];
3596 let mut rec = HashMap::new();
3597 rec.insert("id".to_string(), Value::Int(1));
3598 rec.insert("a".to_string(), Value::String("mix \" and ' and ; here".into()));
3599 let records = vec![Value::Object(rec)];
3600 let out = write_lines(&fields, &records).unwrap();
3601 assert!(out.contains("a[block]"), "{out}");
3602 let back = doc(&out);
3603 assert_eq!(back.to_json(), records_to_json_array(&records));
3604 }
3605
3606 #[test]
3607 fn writer_rejects_an_unwritable_value() {
3608 let fields = vec![FieldDecl::new("a")];
3614 let mut rec = HashMap::new();
3615 rec.insert("a".to_string(), Value::String("mix \" and ' and ; here".into()));
3616 let records = vec![Value::Object(rec)];
3617
3618 let err = write_lines(&fields, &records).unwrap_err();
3619 assert_eq!(err.kind, SynxlErrorKind::Unwritable);
3620
3621 let doc = SynxlDocument {
3623 version: 1,
3624 records,
3625 field_lists: vec![FieldList::new(fields)],
3626 record_field_lists: vec![0],
3627 record_lines: vec![3],
3628 diagnostics: Vec::new(),
3629 };
3630 assert_eq!(doc.to_synxl().unwrap_err().kind, SynxlErrorKind::Unwritable);
3631 }
3632
3633 #[test]
3634 fn writer_keeps_one_column_inline_instead_of_zero_arity() {
3635 let fields = vec![FieldDecl::new("a")];
3638 let mut rec = HashMap::new();
3639 rec.insert("a".to_string(), Value::String("'x\"y".into()));
3640 let records = vec![Value::Object(rec)];
3641 let out = write_lines(&fields, &records).unwrap();
3642 assert!(!out.contains("[block]"), "{out}");
3643 let back = doc(&out);
3644 assert_eq!(back.to_json(), records_to_json_array(&records));
3645 }
3646
3647 #[test]
3648 fn writer_output_is_a_valid_document_without_records() {
3649 let d = doc("!synxl 1\n!fields a ; b\n");
3650 assert_eq!(d.to_synxl().unwrap(), "!synxl 1\n!fields a; b\n");
3651 assert_eq!(doc(&d.to_synxl().unwrap()).to_json(), "[]");
3652 }
3653
3654 #[test]
3655 fn write_lines_emits_the_canonical_shape() {
3656 let d = doc("!synxl 1\n!fields id[type:int] ; name\n1 ; Wario\n");
3657 let out = d.to_synxl().unwrap();
3658 assert_eq!(out, "!synxl 1\n!fields id[type:int]; name\n1; Wario\n");
3659 }
3660
3661 #[test]
3662 fn float_round_trips_through_exponent_form() {
3663 let fields = vec![FieldDecl::new("a"), FieldDecl::new("b"), FieldDecl::new("c")];
3664 let mut rec = HashMap::new();
3665 rec.insert("a".to_string(), Value::Float(1e300));
3666 rec.insert("b".to_string(), Value::Float(5.0));
3667 rec.insert("c".to_string(), Value::Float(f64::NAN));
3668 let records = vec![Value::Object(rec)];
3669 let out = write_lines(&fields, &records).unwrap();
3670 let back = doc(&out);
3671 assert_eq!(back.to_json(), records_to_json_array(&records));
3672 }
3673
3674 #[test]
3677 fn ndjson_projection() {
3678 let d = doc("!synxl 1\n!fields a ; b\n1 ; x\n2 ; y\n");
3679 assert_eq!(d.to_ndjson(), "{\"a\":1,\"b\":\"x\"}\n{\"a\":2,\"b\":\"y\"}\n");
3680 }
3681
3682 #[test]
3683 fn keys_are_sorted_in_the_projection() {
3684 let d = doc("!synxl 1\n!fields z ; a ; m\n1 ; 2 ; 3\n");
3685 assert_eq!(d.to_json(), r#"[{"a":2,"m":3,"z":1}]"#);
3686 }
3687
3688 #[test]
3691 fn splitter_edge_cases() {
3692 let parts: Vec<Part> = PartSplitter::new("a;b").collect();
3693 assert_eq!(parts.len(), 3 - 1);
3694 let parts: Vec<Part> = PartSplitter::new("").collect();
3695 assert_eq!(parts, vec![Part { text: "", quoted: false }]);
3696 let parts: Vec<Part> = PartSplitter::new("'x' ; \"y\"").collect();
3697 assert_eq!(
3698 parts,
3699 vec![
3700 Part { text: "x", quoted: true },
3701 Part { text: "y", quoted: true }
3702 ]
3703 );
3704 let parts: Vec<Part> = PartSplitter::new("'x'y ; z").collect();
3706 assert_eq!(parts[0], Part { text: "'x'y", quoted: false });
3707 let parts: Vec<Part> = PartSplitter::new("\"a;b\"").collect();
3709 assert_eq!(parts, vec![Part { text: "a;b", quoted: true }]);
3710 }
3711}