use std::collections::HashMap;
use std::fmt;
use memchr::memchr;
use crate::parser::{self, ParserOptions};
use crate::value::{Constraints, Value};
pub const SYNXL_VERSION: u32 = 1;
pub const MAX_SYNXL_RECORD_BYTES: usize = 16 * 1024 * 1024;
pub const MAX_SYNXL_FIELDS: usize = 4_096;
pub const MAX_SYNXL_FIELD_NAME_BYTES: usize = 255;
pub const MAX_SYNXL_FIELD_LISTS: usize = 65_536;
pub const MAX_SYNXL_RECORDS: usize = 16_777_216;
const MAX_WRITE_DEPTH: usize = 64;
const FLOAT_EXPANSION_PRECISION: usize = 1_100;
#[derive(Debug, Clone, PartialEq)]
pub struct FieldDecl {
pub name: String,
pub type_hint: Option<String>,
pub constraints: Constraints,
pub block: bool,
}
impl FieldDecl {
pub fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
type_hint: None,
constraints: Constraints::default(),
block: false,
}
}
pub fn new_block(name: impl Into<String>) -> Self {
Self { block: true, ..Self::new(name) }
}
pub fn type_name(&self) -> Option<&str> {
self.type_hint
.as_deref()
.or(self.constraints.type_name.as_deref())
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct FieldList {
fields: Vec<FieldDecl>,
arity: usize,
source: String,
pub line: usize,
}
impl FieldList {
pub fn new(fields: Vec<FieldDecl>) -> Self {
let arity = fields.iter().filter(|f| !f.block).count();
Self { fields, arity, source: String::new(), line: 0 }
}
pub fn source(&self) -> &str {
&self.source
}
pub fn fields(&self) -> &[FieldDecl] {
&self.fields
}
pub fn arity(&self) -> usize {
self.arity
}
pub fn get(&self, name: &str) -> Option<&FieldDecl> {
self.fields.iter().find(|f| f.name == name)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SynxlErrorKind {
MissingPrologue,
UnsupportedVersion,
UnknownDirective,
NoFieldList,
MalformedFieldList,
DuplicateField,
MarkerChain,
NonDeterministicHint,
BlockWithType,
ZeroArity,
Unwritable,
LimitExceeded,
}
impl SynxlErrorKind {
pub fn as_str(self) -> &'static str {
match self {
SynxlErrorKind::MissingPrologue => "MissingPrologue",
SynxlErrorKind::UnsupportedVersion => "UnsupportedVersion",
SynxlErrorKind::UnknownDirective => "UnknownDirective",
SynxlErrorKind::NoFieldList => "NoFieldList",
SynxlErrorKind::MalformedFieldList => "MalformedFieldList",
SynxlErrorKind::DuplicateField => "DuplicateField",
SynxlErrorKind::MarkerChain => "MarkerChain",
SynxlErrorKind::NonDeterministicHint => "NonDeterministicHint",
SynxlErrorKind::BlockWithType => "BlockWithType",
SynxlErrorKind::ZeroArity => "ZeroArity",
SynxlErrorKind::Unwritable => "Unwritable",
SynxlErrorKind::LimitExceeded => "LimitExceeded",
}
}
}
impl fmt::Display for SynxlErrorKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SynxlError {
pub kind: SynxlErrorKind,
pub line: usize,
pub message: String,
}
impl SynxlError {
pub fn new(kind: SynxlErrorKind, line: usize, message: impl Into<String>) -> Self {
Self { kind, line, message: message.into() }
}
}
impl fmt::Display for SynxlError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "SYNXL {} at line {}: {}", self.kind, self.line, self.message)
}
}
impl std::error::Error for SynxlError {}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DiagnosticKind {
MissingFields,
ExtraFields,
CastFailed,
UnknownBlockKey,
BlockFieldNotDeclared,
OrphanBlockLine,
ConstraintViolation,
RecordTruncated,
}
impl DiagnosticKind {
pub fn as_str(self) -> &'static str {
match self {
DiagnosticKind::MissingFields => "MissingFields",
DiagnosticKind::ExtraFields => "ExtraFields",
DiagnosticKind::CastFailed => "CastFailed",
DiagnosticKind::UnknownBlockKey => "UnknownBlockKey",
DiagnosticKind::BlockFieldNotDeclared => "BlockFieldNotDeclared",
DiagnosticKind::OrphanBlockLine => "OrphanBlockLine",
DiagnosticKind::ConstraintViolation => "ConstraintViolation",
DiagnosticKind::RecordTruncated => "RecordTruncated",
}
}
}
impl fmt::Display for DiagnosticKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Diagnostic {
pub record_index: usize,
pub line: usize,
pub kind: DiagnosticKind,
pub message: String,
}
impl fmt::Display for Diagnostic {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"{} (record {}, line {}): {}",
self.kind, self.record_index, self.line, self.message
)
}
}
#[derive(Debug, Clone, Default)]
pub struct SynxlOptions {
pub validate: bool,
}
#[derive(Debug, Clone, PartialEq)]
pub struct SynxlRecord {
pub index: usize,
pub line: usize,
pub field_list: usize,
pub value: Value,
pub diagnostics: Vec<Diagnostic>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct SynxlDocument {
pub version: u32,
pub records: Vec<Value>,
pub field_lists: Vec<FieldList>,
pub record_field_lists: Vec<usize>,
pub record_lines: Vec<usize>,
pub diagnostics: Vec<Diagnostic>,
}
impl SynxlDocument {
pub fn len(&self) -> usize {
self.records.len()
}
pub fn is_empty(&self) -> bool {
self.records.is_empty()
}
pub fn field_list_for(&self, index: usize) -> Option<&FieldList> {
self.record_field_lists
.get(index)
.and_then(|i| self.field_lists.get(*i))
}
pub fn to_json(&self) -> String {
records_to_json_array(&self.records)
}
pub fn to_ndjson(&self) -> String {
records_to_ndjson(&self.records)
}
pub fn to_synxl(&self) -> Result<String, SynxlError> {
write_document(self)
}
}
pub fn records_to_json_array(records: &[Value]) -> String {
let mut out = String::with_capacity(records.len() * 64 + 2);
out.push('[');
for (i, rec) in records.iter().enumerate() {
if i > 0 {
out.push(',');
}
crate::write_json(&mut out, rec);
}
out.push(']');
out
}
pub fn records_to_ndjson(records: &[Value]) -> String {
let mut out = String::with_capacity(records.len() * 64);
for rec in records {
crate::write_json(&mut out, rec);
out.push('\n');
}
out
}
#[derive(Debug, Clone, Copy)]
struct Line {
start: usize,
end: usize,
no: usize,
}
impl Line {
#[inline]
fn raw<'t>(&self, text: &'t str) -> &'t str {
&text[self.start..self.end]
}
#[inline]
fn indent(&self, text: &str) -> usize {
let raw = self.raw(text);
raw.len() - raw.trim_start().len()
}
}
enum LineKind {
Skip,
Record,
}
struct RecordFrame<'t> {
line: &'t str,
line_no: usize,
block: &'t str,
block_first_no: usize,
truncated_total: Option<usize>,
}
#[derive(Debug)]
struct ReaderCore {
pos: usize,
line_no: usize,
pending: Option<Line>,
pending_diagnostics: Vec<Diagnostic>,
in_block_comment: bool,
field_lists: Vec<FieldList>,
current: Option<usize>,
record_index: usize,
version: u32,
done: bool,
opts: SynxlOptions,
}
impl ReaderCore {
fn empty(opts: SynxlOptions) -> Self {
Self {
pos: 0,
line_no: 1,
pending: None,
pending_diagnostics: Vec::new(),
in_block_comment: false,
field_lists: Vec::new(),
current: None,
record_index: 0,
version: 0,
done: false,
opts,
}
}
fn start(text: &str, opts: SynxlOptions) -> Result<Self, SynxlError> {
let mut core = Self {
pos: if text.starts_with('\u{feff}') {
'\u{feff}'.len_utf8()
} else {
0
},
line_no: 1,
pending: None,
pending_diagnostics: Vec::new(),
in_block_comment: false,
field_lists: Vec::new(),
current: None,
record_index: 0,
version: 0,
done: false,
opts,
};
core.read_prologue(text)?;
Ok(core)
}
fn read_line(&mut self, text: &str) -> Option<Line> {
if let Some(line) = self.pending.take() {
return Some(line);
}
let bytes = text.as_bytes();
if self.pos >= bytes.len() {
return None;
}
let start = self.pos;
let (mut end, next) = match memchr(b'\n', &bytes[start..]) {
Some(rel) => (start + rel, start + rel + 1),
None => (bytes.len(), bytes.len()),
};
if end > start && bytes[end - 1] == b'\r' {
end -= 1;
}
self.pos = next;
let no = self.line_no;
self.line_no += 1;
Some(Line { start, end, no })
}
fn next_item(&mut self, text: &str) -> Option<Result<SynxlRecord, SynxlError>> {
if self.done {
return None;
}
match self.next_record(text) {
Ok(Some(rec)) => Some(Ok(rec)),
Ok(None) => {
self.done = true;
None
}
Err(err) => {
self.done = true;
Some(Err(err))
}
}
}
fn prologue_skip(&mut self, trimmed: &str) -> bool {
if trimmed.is_empty() {
return true;
}
if trimmed == "###" {
self.in_block_comment = !self.in_block_comment;
return true;
}
if self.in_block_comment {
return true;
}
trimmed.starts_with('#') || trimmed.starts_with("//")
}
fn missing_prologue(last_line: usize) -> SynxlError {
SynxlError::new(
SynxlErrorKind::MissingPrologue,
last_line.max(1),
"document has no `!synxl <version>` prologue",
)
}
fn read_prologue(&mut self, text: &str) -> Result<(), SynxlError> {
while let Some(line) = self.read_line(text) {
let trimmed = line.raw(text).trim();
if self.prologue_skip(trimmed) {
continue;
}
self.version = parse_prologue(trimmed, line.no)?;
return Ok(());
}
Err(Self::missing_prologue(self.line_no.saturating_sub(1)))
}
fn note_orphan(&mut self, trimmed: &str, no: usize) {
self.pending_diagnostics.push(Diagnostic {
record_index: self.record_index,
line: no,
kind: DiagnosticKind::OrphanBlockLine,
message: format!("indented line `{}` has no open record; discarded", elide(trimmed)),
});
}
fn classify(&mut self, trimmed: &str, no: usize) -> Result<LineKind, SynxlError> {
if trimmed == "###" {
self.in_block_comment = !self.in_block_comment;
return Ok(LineKind::Skip);
}
if self.in_block_comment {
return Ok(LineKind::Skip);
}
if trimmed.starts_with('!') {
if let Some(rest) = trimmed.strip_prefix("!fields") {
if rest.is_empty() || starts_with_wsp(rest) {
let list = parse_field_list(rest.trim(), trimmed, no)?;
self.push_field_list(list)?;
return Ok(LineKind::Skip);
}
}
if let Some(rest) = trimmed.strip_prefix("!synxl") {
if rest.is_empty() || starts_with_wsp(rest) {
parse_prologue(trimmed, no)?;
return Ok(LineKind::Skip);
}
}
return Err(SynxlError::new(
SynxlErrorKind::UnknownDirective,
no,
format!(
"`{}` is neither a prologue nor a field list; a record starting with `!` must quote it",
elide(trimmed)
),
));
}
if trimmed.starts_with('#') || trimmed.starts_with("//") {
return Ok(LineKind::Skip);
}
Ok(LineKind::Record)
}
fn require_field_list(&self, no: usize) -> Result<usize, SynxlError> {
self.current.ok_or_else(|| {
SynxlError::new(
SynxlErrorKind::NoFieldList,
no,
"record line with no `!fields` in effect",
)
})
}
fn push_field_list(&mut self, list: FieldList) -> Result<(), SynxlError> {
if self.field_lists.len() >= MAX_SYNXL_FIELD_LISTS {
return Err(SynxlError::new(
SynxlErrorKind::LimitExceeded,
list.line,
format!("more than {} field lists in one document", MAX_SYNXL_FIELD_LISTS),
));
}
self.field_lists.push(list);
self.current = Some(self.field_lists.len() - 1);
Ok(())
}
fn next_record(&mut self, text: &str) -> Result<Option<SynxlRecord>, SynxlError> {
loop {
let line = match self.read_line(text) {
Some(l) => l,
None => return Ok(None),
};
let trimmed = line.raw(text).trim();
if trimmed.is_empty() {
continue;
}
if line.indent(text) > 0 {
self.note_orphan(trimmed, line.no);
continue;
}
if let LineKind::Skip = self.classify(trimmed, line.no)? {
continue;
}
let fl_idx = self.require_field_list(line.no)?;
let mut block_start: Option<usize> = None;
let mut block_end: Option<usize> = None;
let mut block_first_no = 0usize;
loop {
let next = match self.read_line(text) {
Some(l) => l,
None => break,
};
if next.raw(text).trim().is_empty() {
continue;
}
if next.indent(text) == 0 {
self.pending = Some(next);
break;
}
if block_start.is_none() {
block_start = Some(next.start);
block_first_no = next.no;
}
block_end = Some(next.end);
}
let block_raw = match (block_start, block_end) {
(Some(s), Some(e)) => &text[s..e],
_ => "",
};
let mut record_line = line.raw(text);
let mut block_text = block_raw;
let total = record_line.len().saturating_add(block_text.len());
let mut truncated_total = None;
if total > MAX_SYNXL_RECORD_BYTES {
truncated_total = Some(total);
if record_line.len() >= MAX_SYNXL_RECORD_BYTES {
record_line = truncate_utf8(record_line, MAX_SYNXL_RECORD_BYTES);
block_text = "";
} else {
block_text =
truncate_utf8(block_text, MAX_SYNXL_RECORD_BYTES - record_line.len());
}
}
return self
.build(
fl_idx,
RecordFrame {
line: record_line,
line_no: line.no,
block: block_text,
block_first_no,
truncated_total,
},
)
.map(Some);
}
}
fn build(&mut self, fl_idx: usize, frame: RecordFrame<'_>) -> Result<SynxlRecord, SynxlError> {
let RecordFrame {
line: record_line,
line_no,
block: block_text,
block_first_no,
truncated_total,
} = frame;
let index = self.record_index;
self.record_index += 1;
let mut diagnostics: Vec<Diagnostic> = std::mem::take(&mut self.pending_diagnostics);
if let Some(total) = truncated_total {
diagnostics.push(Diagnostic {
record_index: index,
line: line_no,
kind: DiagnosticKind::RecordTruncated,
message: format!(
"record is {} bytes, truncated to the {} byte limit",
total, MAX_SYNXL_RECORD_BYTES
),
});
}
let block_root = if block_text.trim().is_empty() {
None
} else {
match parser::parse_with(block_text, ParserOptions { directives: false }).root {
Value::Object(map) => Some(map),
_ => None,
}
};
let fl = &self.field_lists[fl_idx];
let mut obj: HashMap<String, Value> = HashMap::with_capacity(fl.fields.len());
if record_line.trim() == ";" {
for field in fl.fields.iter().filter(|f| !f.block) {
obj.insert(field.name.clone(), Value::Null);
}
} else {
let mut inline_fields = fl.fields.iter().filter(|f| !f.block);
let mut parts = PartSplitter::new(record_line);
let mut part_count = 0usize;
loop {
let part = match parts.next() {
Some(p) => p,
None => break,
};
part_count += 1;
match inline_fields.next() {
Some(field) => {
let value = cast_part(field, part, index, line_no, &mut diagnostics);
obj.insert(field.name.clone(), value);
}
None => {}
}
}
let mut missing = 0usize;
for field in inline_fields {
obj.insert(field.name.clone(), Value::Null);
missing += 1;
}
if missing > 0 {
diagnostics.push(Diagnostic {
record_index: index,
line: line_no,
kind: DiagnosticKind::MissingFields,
message: format!(
"record has {} inline part(s), field list declares {}; {} trailing field(s) set to null",
part_count,
fl.arity(),
missing
),
});
} else if part_count > fl.arity() {
diagnostics.push(Diagnostic {
record_index: index,
line: line_no,
kind: DiagnosticKind::ExtraFields,
message: format!(
"record has {} inline part(s), field list declares {}; {} discarded",
part_count,
fl.arity(),
part_count - fl.arity()
),
});
}
}
for field in fl.fields.iter().filter(|f| f.block) {
obj.insert(field.name.clone(), Value::Null);
}
let mut key_lines: Option<HashMap<&str, usize>> = None;
if let Some(map) = block_root {
let mut entries: Vec<(String, Value)> = map.into_iter().collect();
entries.sort_unstable_by(|a, b| a.0.cmp(&b.0));
for (key, value) in entries {
match fl.get(&key) {
Some(field) if field.block => {
obj.insert(field.name.clone(), value);
}
Some(_) => {
let at = key_lines
.get_or_insert_with(|| block_key_lines(block_text, block_first_no))
.get(key.as_str())
.copied()
.unwrap_or(line_no);
diagnostics.push(Diagnostic {
record_index: index,
line: at,
kind: DiagnosticKind::BlockFieldNotDeclared,
message: format!(
"block key `{}` matches a field that is not declared [block]; inline value kept",
key
),
});
}
None => {
let at = key_lines
.get_or_insert_with(|| block_key_lines(block_text, block_first_no))
.get(key.as_str())
.copied()
.unwrap_or(line_no);
diagnostics.push(Diagnostic {
record_index: index,
line: at,
kind: DiagnosticKind::UnknownBlockKey,
message: format!("block key `{}` matches no declared field", key),
});
}
}
}
}
if self.opts.validate {
for field in fl.fields.iter() {
if let Some(value) = obj.get(&field.name) {
if let Some(msg) = check_constraints(&field.name, value, &field.constraints) {
let at = if field.block {
key_lines
.get_or_insert_with(|| block_key_lines(block_text, block_first_no))
.get(field.name.as_str())
.copied()
.unwrap_or(line_no)
} else {
line_no
};
diagnostics.push(Diagnostic {
record_index: index,
line: at,
kind: DiagnosticKind::ConstraintViolation,
message: msg,
});
}
}
}
}
Ok(SynxlRecord {
index,
line: line_no,
field_list: fl_idx,
value: Value::Object(obj),
diagnostics,
})
}
}
#[derive(Debug)]
pub struct SynxlReader<'a> {
text: &'a str,
core: ReaderCore,
}
impl<'a> SynxlReader<'a> {
pub fn new(text: &'a str) -> Result<Self, SynxlError> {
Self::with_options(text, SynxlOptions::default())
}
pub fn with_options(text: &'a str, opts: SynxlOptions) -> Result<Self, SynxlError> {
Ok(Self { text, core: ReaderCore::start(text, opts)? })
}
pub fn version(&self) -> u32 {
self.core.version
}
pub fn field_lists(&self) -> &[FieldList] {
&self.core.field_lists
}
pub fn field_list(&self) -> Option<&FieldList> {
self.core.current.map(|i| &self.core.field_lists[i])
}
pub fn into_field_lists(self) -> Vec<FieldList> {
self.core.field_lists
}
pub fn trailing_diagnostics(&self) -> &[Diagnostic] {
&self.core.pending_diagnostics
}
}
impl Iterator for SynxlReader<'_> {
type Item = Result<SynxlRecord, SynxlError>;
fn next(&mut self) -> Option<Self::Item> {
self.core.next_item(self.text)
}
}
#[derive(Debug)]
pub struct SynxlReaderOwned {
text: String,
core: ReaderCore,
}
impl SynxlReaderOwned {
pub fn new(text: String) -> Result<Self, SynxlError> {
Self::with_options(text, SynxlOptions::default())
}
pub fn with_options(text: String, opts: SynxlOptions) -> Result<Self, SynxlError> {
let core = ReaderCore::start(&text, opts)?;
Ok(Self { text, core })
}
pub fn version(&self) -> u32 {
self.core.version
}
pub fn field_lists(&self) -> &[FieldList] {
&self.core.field_lists
}
pub fn field_list(&self) -> Option<&FieldList> {
self.core.current.map(|i| &self.core.field_lists[i])
}
pub fn into_field_lists(self) -> Vec<FieldList> {
self.core.field_lists
}
pub fn trailing_diagnostics(&self) -> &[Diagnostic] {
&self.core.pending_diagnostics
}
pub fn text(&self) -> &str {
&self.text
}
pub fn into_text(self) -> String {
self.text
}
}
impl Iterator for SynxlReaderOwned {
type Item = Result<SynxlRecord, SynxlError>;
fn next(&mut self) -> Option<Self::Item> {
self.core.next_item(&self.text)
}
}
#[derive(Debug)]
pub enum SynxlStreamError {
Format(SynxlError),
Io(std::io::Error),
}
impl SynxlStreamError {
pub fn as_format(&self) -> Option<&SynxlError> {
match self {
SynxlStreamError::Format(e) => Some(e),
SynxlStreamError::Io(_) => None,
}
}
pub fn as_io(&self) -> Option<&std::io::Error> {
match self {
SynxlStreamError::Io(e) => Some(e),
SynxlStreamError::Format(_) => None,
}
}
}
impl fmt::Display for SynxlStreamError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
SynxlStreamError::Format(e) => write!(f, "{}", e),
SynxlStreamError::Io(e) => write!(f, "I/O error while reading SYNXL: {}", e),
}
}
}
impl std::error::Error for SynxlStreamError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
SynxlStreamError::Format(e) => Some(e),
SynxlStreamError::Io(e) => Some(e),
}
}
}
impl From<SynxlError> for SynxlStreamError {
fn from(e: SynxlError) -> Self {
SynxlStreamError::Format(e)
}
}
impl From<std::io::Error> for SynxlStreamError {
fn from(e: std::io::Error) -> Self {
SynxlStreamError::Io(e)
}
}
#[derive(Debug)]
pub struct SynxlStreamReader<R: std::io::BufRead> {
inner: R,
core: ReaderCore,
scratch: Vec<u8>,
cur: String,
cur_no: usize,
cur_dropped: usize,
lookahead: Option<(String, usize, usize)>,
line_no: usize,
record: String,
block: String,
deferred: String,
done: bool,
}
impl<R: std::io::BufRead> SynxlStreamReader<R> {
pub fn new(inner: R) -> Result<Self, SynxlStreamError> {
Self::with_options(inner, SynxlOptions::default())
}
pub fn with_options(inner: R, opts: SynxlOptions) -> Result<Self, SynxlStreamError> {
let mut reader = Self {
inner,
core: ReaderCore::empty(opts),
scratch: Vec::with_capacity(256),
cur: String::with_capacity(256),
cur_no: 0,
cur_dropped: 0,
lookahead: None,
line_no: 0,
record: String::new(),
block: String::new(),
deferred: String::new(),
done: false,
};
reader.read_prologue()?;
Ok(reader)
}
pub fn version(&self) -> u32 {
self.core.version
}
pub fn field_lists(&self) -> &[FieldList] {
&self.core.field_lists
}
pub fn field_list(&self) -> Option<&FieldList> {
self.core.current.map(|i| &self.core.field_lists[i])
}
pub fn into_field_lists(self) -> Vec<FieldList> {
self.core.field_lists
}
pub fn trailing_diagnostics(&self) -> &[Diagnostic] {
&self.core.pending_diagnostics
}
pub fn into_inner(self) -> R {
self.inner
}
fn advance(&mut self) -> Result<bool, SynxlStreamError> {
if let Some((text, no, dropped)) = self.lookahead.take() {
self.cur = text;
self.cur_no = no;
self.cur_dropped = dropped;
return Ok(true);
}
let dropped =
match read_capped_line(&mut self.inner, &mut self.scratch, MAX_SYNXL_RECORD_BYTES)? {
Some(d) => d,
None => return Ok(false),
};
if dropped > 0 {
for _ in 0..3 {
if std::str::from_utf8(&self.scratch).is_ok() {
break;
}
self.scratch.pop();
}
}
let text = std::str::from_utf8(&self.scratch).map_err(|e| {
std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("line {} is not valid UTF-8 (§3.1): {}", self.line_no + 1, e),
)
})?;
self.cur.clear();
self.cur.push_str(if self.line_no == 0 {
text.strip_prefix('\u{feff}').unwrap_or(text)
} else {
text
});
self.line_no += 1;
self.cur_no = self.line_no;
self.cur_dropped = dropped;
Ok(true)
}
fn push_back(&mut self) {
self.lookahead = Some((std::mem::take(&mut self.cur), self.cur_no, self.cur_dropped));
}
fn read_prologue(&mut self) -> Result<(), SynxlStreamError> {
loop {
if !self.advance()? {
return Err(ReaderCore::missing_prologue(self.line_no).into());
}
let trimmed = self.cur.trim();
if self.core.prologue_skip(trimmed) {
continue;
}
self.core.version = parse_prologue(trimmed, self.cur_no)?;
return Ok(());
}
}
fn next_record(&mut self) -> Result<Option<SynxlRecord>, SynxlStreamError> {
loop {
if !self.advance()? {
return Ok(None);
}
let trimmed = self.cur.trim();
if trimmed.is_empty() {
continue;
}
if self.cur.len() - self.cur.trim_start().len() > 0 {
let no = self.cur_no;
self.core.note_orphan(trimmed, no);
continue;
}
if let LineKind::Skip = self.core.classify(trimmed, self.cur_no)? {
continue;
}
let record_no = self.cur_no;
let fl_idx = self.core.require_field_list(record_no)?;
self.record.clear();
self.record.push_str(&self.cur);
let mut total = self.record.len() + self.cur_dropped;
self.block.clear();
self.deferred.clear();
let mut block_first_no = 0usize;
loop {
if !self.advance()? {
break;
}
if self.cur.trim().is_empty() {
if !self.block.is_empty() {
self.deferred.push('\n');
self.deferred.push_str(&self.cur);
}
continue;
}
if self.cur.len() - self.cur.trim_start().len() == 0 {
self.push_back();
break;
}
if block_first_no == 0 {
block_first_no = self.cur_no;
}
let sep = usize::from(!self.block.is_empty());
total += self.deferred.len() + sep + self.cur.len() + self.cur_dropped;
let room = (MAX_SYNXL_RECORD_BYTES + self.deferred.len())
.saturating_sub(self.record.len() + self.block.len());
if room > self.deferred.len() + sep {
let budget = room - self.deferred.len() - sep;
self.block.push_str(&self.deferred);
if sep == 1 {
self.block.push('\n');
}
let piece_len = truncate_utf8(&self.cur, budget).len();
self.block.push_str(&self.cur[..piece_len]);
}
self.deferred.clear();
}
let truncated_total = if total > MAX_SYNXL_RECORD_BYTES { Some(total) } else { None };
let frame = RecordFrame {
line: &self.record,
line_no: record_no,
block: &self.block,
block_first_no,
truncated_total,
};
return self.core.build(fl_idx, frame).map(Some).map_err(Into::into);
}
}
}
impl<R: std::io::BufRead> Iterator for SynxlStreamReader<R> {
type Item = Result<SynxlRecord, SynxlStreamError>;
fn next(&mut self) -> Option<Self::Item> {
if self.done {
return None;
}
match self.next_record() {
Ok(Some(rec)) => Some(Ok(rec)),
Ok(None) => {
self.done = true;
None
}
Err(err) => {
self.done = true;
Some(Err(err))
}
}
}
}
fn read_capped_line<R: std::io::BufRead>(
reader: &mut R,
out: &mut Vec<u8>,
cap: usize,
) -> std::io::Result<Option<usize>> {
out.clear();
let mut dropped = 0usize;
let mut saw_any = false;
loop {
let (consumed, done) = {
let available = match reader.fill_buf() {
Ok(b) => b,
Err(ref e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
Err(e) => return Err(e),
};
if available.is_empty() {
break;
}
saw_any = true;
match memchr(b'\n', available) {
Some(i) => {
push_capped(out, &available[..i], cap, &mut dropped);
(i + 1, true)
}
None => {
let len = available.len();
push_capped(out, available, cap, &mut dropped);
(len, false)
}
}
};
reader.consume(consumed);
if done {
break;
}
}
if !saw_any {
return Ok(None);
}
if out.last() == Some(&b'\r') {
out.pop();
}
Ok(Some(dropped))
}
fn push_capped(out: &mut Vec<u8>, chunk: &[u8], cap: usize, dropped: &mut usize) {
let room = cap.saturating_sub(out.len());
let take = room.min(chunk.len());
out.extend_from_slice(&chunk[..take]);
*dropped += chunk.len() - take;
}
pub fn parse_lines(text: &str) -> Result<SynxlDocument, SynxlError> {
parse_lines_with(text, &SynxlOptions::default())
}
pub fn parse_lines_with(text: &str, opts: &SynxlOptions) -> Result<SynxlDocument, SynxlError> {
let mut reader = SynxlReader::with_options(text, opts.clone())?;
let mut records = Vec::new();
let mut record_field_lists = Vec::new();
let mut record_lines = Vec::new();
let mut diagnostics = Vec::new();
loop {
let item = match reader.next() {
Some(item) => item,
None => break,
};
let mut rec = item?;
if records.len() >= MAX_SYNXL_RECORDS {
return Err(SynxlError::new(
SynxlErrorKind::LimitExceeded,
rec.line,
format!("more than {} records in an in-memory parse", MAX_SYNXL_RECORDS),
));
}
records.push(rec.value);
record_field_lists.push(rec.field_list);
record_lines.push(rec.line);
diagnostics.append(&mut rec.diagnostics);
}
diagnostics.extend_from_slice(reader.trailing_diagnostics());
let version = reader.version();
Ok(SynxlDocument {
version,
records,
field_lists: reader.into_field_lists(),
record_field_lists,
record_lines,
diagnostics,
})
}
fn block_key_lines(block: &str, first_line_no: usize) -> HashMap<&str, usize> {
let mut min_indent = usize::MAX;
for line in block.split('\n') {
let line = line.strip_suffix('\r').unwrap_or(line);
if line.trim().is_empty() {
continue;
}
min_indent = min_indent.min(line.len() - line.trim_start().len());
}
let mut map: HashMap<&str, usize> = HashMap::new();
for (i, line) in block.split('\n').enumerate() {
let line = line.strip_suffix('\r').unwrap_or(line);
let trimmed = line.trim();
if trimmed.is_empty() || line.len() - line.trim_start().len() != min_indent {
continue;
}
if trimmed.starts_with("- ") || trimmed.starts_with('!') {
continue;
}
match trimmed.as_bytes()[0] {
b'[' | b':' | b'-' | b'#' | b'/' | b'(' => continue,
_ => {}
}
let key_end = trimmed
.find(|c: char| c == ' ' || c == '\t' || c == '[' || c == ':' || c == '(')
.unwrap_or(trimmed.len());
map.entry(&trimmed[..key_end]).or_insert(first_line_no + i);
}
map
}
fn parse_prologue(trimmed: &str, line: usize) -> Result<u32, SynxlError> {
let rest = match trimmed.strip_prefix("!synxl") {
Some(r) if starts_with_wsp(r) => r,
_ => {
return Err(SynxlError::new(
SynxlErrorKind::MissingPrologue,
line,
format!("expected `!synxl <version>`, found `{}`", elide(trimmed)),
))
}
};
let version = rest.trim();
if version.is_empty() || !version.bytes().all(|b| b.is_ascii_digit()) {
return Err(SynxlError::new(
SynxlErrorKind::MissingPrologue,
line,
format!("prologue version must be a decimal integer, found `{}`", elide(version)),
));
}
match version.parse::<u32>() {
Ok(v) if v == SYNXL_VERSION => Ok(v),
_ => Err(SynxlError::new(
SynxlErrorKind::UnsupportedVersion,
line,
format!("SYNXL version `{}` is not supported (this build implements {})", version, SYNXL_VERSION),
)),
}
}
fn parse_field_list(rest: &str, source: &str, line: usize) -> Result<FieldList, SynxlError> {
if rest.is_empty() {
return Err(SynxlError::new(
SynxlErrorKind::MalformedFieldList,
line,
"`!fields` declares no fields",
));
}
let mut fields: Vec<FieldDecl> = Vec::new();
for decl in rest.split(';') {
let decl = decl.trim();
if decl.is_empty() {
return Err(SynxlError::new(
SynxlErrorKind::MalformedFieldList,
line,
"empty field declaration",
));
}
if fields.len() >= MAX_SYNXL_FIELDS {
return Err(SynxlError::new(
SynxlErrorKind::LimitExceeded,
line,
format!("more than {} fields in one field list", MAX_SYNXL_FIELDS),
));
}
let field = parse_field_decl(decl, line)?;
if fields.iter().any(|f| f.name == field.name) {
return Err(SynxlError::new(
SynxlErrorKind::DuplicateField,
line,
format!("duplicate field name `{}`", field.name),
));
}
fields.push(field);
}
let list = FieldList::new(fields);
if list.arity() == 0 {
return Err(SynxlError::new(
SynxlErrorKind::ZeroArity,
line,
"field list has arity 0 — every field is declared [block]; at least one inline field is required",
));
}
let mut list = list;
list.line = line;
list.source = source.to_string();
Ok(list)
}
fn parse_field_decl(decl: &str, line: usize) -> Result<FieldDecl, SynxlError> {
let bytes = decl.as_bytes();
let len = bytes.len();
let mut pos = 0usize;
while pos < len {
let ch = bytes[pos];
if ch == b' ' || ch == b'\t' || ch == b'[' || ch == b'(' || ch == b':' {
break;
}
pos += 1;
}
let name = &decl[..pos];
if name.is_empty() {
return Err(SynxlError::new(
SynxlErrorKind::MalformedFieldList,
line,
format!("field declaration `{}` has no name", elide(decl)),
));
}
if name.len() > MAX_SYNXL_FIELD_NAME_BYTES {
return Err(SynxlError::new(
SynxlErrorKind::LimitExceeded,
line,
format!(
"field name is {} bytes, limit is {}",
name.len(),
MAX_SYNXL_FIELD_NAME_BYTES
),
));
}
let mut type_hint = None;
if pos < len && bytes[pos] == b'(' {
let start = pos + 1;
match decl[start..].find(')') {
Some(rel) => {
type_hint = Some(decl[start..start + rel].trim().to_string());
pos = start + rel + 1;
}
None => {
return Err(SynxlError::new(
SynxlErrorKind::MalformedFieldList,
line,
format!("unterminated `(` in field declaration `{}`", elide(decl)),
))
}
}
}
let mut constraints = Constraints::default();
let mut block = false;
if pos < len && bytes[pos] == b'[' {
let cstart = pos + 1;
let mut depth = 1usize;
let mut scan = cstart;
while scan < len {
match bytes[scan] {
b'[' => depth += 1,
b']' => {
depth -= 1;
if depth == 0 {
break;
}
}
_ => {}
}
scan += 1;
}
if depth != 0 {
return Err(SynxlError::new(
SynxlErrorKind::MalformedFieldList,
line,
format!("unterminated `[` in field declaration `{}`", elide(decl)),
));
}
let raw = &decl[cstart..scan];
constraints = parser::parse_constraints(raw);
block = raw
.split(',')
.map(|p| p.trim())
.any(|p| p == "block");
pos = scan + 1;
}
let tail = decl[pos..].trim();
if !tail.is_empty() {
if tail.starts_with(':') {
return Err(SynxlError::new(
SynxlErrorKind::MarkerChain,
line,
format!("marker chain `{}` is not allowed in a field declaration", elide(tail)),
));
}
return Err(SynxlError::new(
SynxlErrorKind::MalformedFieldList,
line,
format!("trailing `{}` in field declaration `{}`", elide(tail), elide(decl)),
));
}
for hint in [type_hint.as_deref(), constraints.type_name.as_deref()]
.into_iter()
.flatten()
{
if is_non_deterministic_hint(hint) {
return Err(SynxlError::new(
SynxlErrorKind::NonDeterministicHint,
line,
format!("non-deterministic type hint `{}` on field `{}`", hint, name),
));
}
}
if block && (type_hint.is_some() || constraints.type_name.is_some()) {
return Err(SynxlError::new(
SynxlErrorKind::BlockWithType,
line,
format!("field `{}` combines `block` with a type", name),
));
}
Ok(FieldDecl {
name: name.to_string(),
type_hint,
constraints,
block,
})
}
fn is_non_deterministic_hint(hint: &str) -> bool {
matches!(hint, "random" | "random:int" | "random:float" | "random:bool")
}
#[derive(Debug, Clone, Copy, PartialEq)]
struct Part<'a> {
text: &'a str,
quoted: bool,
}
struct PartSplitter<'a> {
s: &'a str,
b: &'a [u8],
pos: usize,
finished: bool,
}
impl<'a> PartSplitter<'a> {
fn new(s: &'a str) -> Self {
Self { s, b: s.as_bytes(), pos: 0, finished: false }
}
}
impl<'a> Iterator for PartSplitter<'a> {
type Item = Part<'a>;
fn next(&mut self) -> Option<Part<'a>> {
if self.finished {
return None;
}
let len = self.b.len();
let mut i = self.pos;
while i < len && (self.b[i] == b' ' || self.b[i] == b'\t') {
i += 1;
}
if i < len && (self.b[i] == b'"' || self.b[i] == b'\'') {
let quote = self.b[i];
if let Some(rel) = memchr(quote, &self.b[i + 1..]) {
let close = i + 1 + rel;
let mut j = close + 1;
while j < len && (self.b[j] == b' ' || self.b[j] == b'\t') {
j += 1;
}
if j >= len || self.b[j] == b';' {
let part = Part { text: &self.s[i + 1..close], quoted: true };
if j >= len {
self.finished = true;
self.pos = len;
} else {
self.pos = j + 1;
}
return Some(part);
}
}
}
let end = match memchr(b';', &self.b[i..]) {
Some(rel) => i + rel,
None => len,
};
let text = trim_wsp(&self.s[i..end]);
if end >= len {
self.finished = true;
self.pos = len;
} else {
self.pos = end + 1;
}
Some(Part { text, quoted: false })
}
}
fn cast_part(
field: &FieldDecl,
part: Part<'_>,
record_index: usize,
line: usize,
diagnostics: &mut Vec<Diagnostic>,
) -> Value {
if part.quoted {
return Value::String(part.text.to_string());
}
if part.text.is_empty() {
return Value::Null;
}
match field.type_name() {
Some(hint) => match cast_typed_checked(part.text, hint) {
Some(v) => v,
None => {
diagnostics.push(Diagnostic {
record_index,
line,
kind: DiagnosticKind::CastFailed,
message: format!(
"field `{}`: `{}` is not a valid {}",
field.name,
elide(part.text),
hint
),
});
Value::Null
}
},
None => cast_inline(part.text),
}
}
fn cast_inline(raw: &str) -> Value {
if is_quote_wrapped(raw) {
return Value::String(raw.to_string());
}
parser::cast(raw)
}
fn cast_typed_checked(raw: &str, hint: &str) -> Option<Value> {
match hint {
"int" => raw.parse::<i64>().ok().map(Value::Int),
"float" => raw.parse::<f64>().ok().filter(|f| f.is_finite()).map(Value::Float),
"bool" => match raw {
"true" => Some(Value::Bool(true)),
"false" => Some(Value::Bool(false)),
_ => None,
},
"string" => Some(Value::String(raw.to_string())),
_ => Some(cast_inline(raw)),
}
}
fn check_constraints(key: &str, value: &Value, c: &Constraints) -> Option<String> {
if c.required {
let empty = matches!(value, Value::Null)
|| matches!(value, Value::String(s) if s.is_empty());
if empty {
return Some(format!("`{}` is required", key));
}
}
if matches!(value, Value::Null) {
return None;
}
if let Some(ref type_name) = c.type_name {
let ok = match type_name.as_str() {
"int" => matches!(value, Value::Int(_)),
"float" => matches!(value, Value::Float(_) | Value::Int(_)),
"bool" => matches!(value, Value::Bool(_)),
"string" => matches!(value, Value::String(_)),
_ => true,
};
if !ok {
return Some(format!("`{}` expected type `{}`", key, type_name));
}
}
if let Some(ref enum_values) = c.enum_values {
let as_str = match value {
Value::String(s) => s.clone(),
Value::Int(n) => n.to_string(),
Value::Float(f) => f.to_string(),
Value::Bool(b) => b.to_string(),
_ => String::new(),
};
if !enum_values.contains(&as_str) {
return Some(format!("`{}` must be one of [{}]", key, enum_values.join("|")));
}
}
let num = match value {
Value::Int(n) => Some(*n as f64),
Value::Float(f) => Some(*f),
Value::String(s) if c.min.is_some() || c.max.is_some() => Some(s.len() as f64),
_ => None,
};
if let Some(n) = num {
if let Some(min) = c.min {
if n < min {
return Some(format!("`{}` value {} is below min {}", key, n, min));
}
}
if let Some(max) = c.max {
if n > max {
return Some(format!("`{}` value {} exceeds max {}", key, n, max));
}
}
}
if let Some(ref pattern) = c.pattern {
if pattern.len() <= 256 {
if let Value::String(ref s) = value {
if let Ok(re) = regex::Regex::new(pattern) {
if !re.is_match(s) {
return Some(format!("`{}` does not match pattern /{}/", key, pattern));
}
}
}
}
}
None
}
pub fn write_document(doc: &SynxlDocument) -> Result<String, SynxlError> {
let mut out = String::with_capacity(doc.records.len() * 64 + 64);
out.push_str("!synxl 1\n");
if doc.records.is_empty() {
if let Some(fl) = doc.field_lists.first() {
write_group(&mut out, fl.fields(), &[])?;
}
return Ok(out);
}
let mut start = 0usize;
while start < doc.records.len() {
let fl_idx = doc.record_field_lists.get(start).copied().unwrap_or(0);
let mut end = start + 1;
while end < doc.records.len()
&& doc.record_field_lists.get(end).copied().unwrap_or(0) == fl_idx
{
end += 1;
}
let empty = FieldList::new(Vec::new());
let fl = doc.field_lists.get(fl_idx).unwrap_or(&empty);
write_group(&mut out, fl.fields(), &doc.records[start..end])?;
start = end;
}
Ok(out)
}
pub fn write_lines(fields: &[FieldDecl], records: &[Value]) -> Result<String, SynxlError> {
let mut out = String::with_capacity(records.len() * 64 + 64);
out.push_str("!synxl 1\n");
write_group(&mut out, fields, records)?;
Ok(out)
}
fn write_group(
out: &mut String,
fields: &[FieldDecl],
records: &[Value],
) -> Result<(), SynxlError> {
let mut block: Vec<bool> = fields
.iter()
.map(|f| {
f.block
|| records.iter().any(|r| {
r.as_object()
.and_then(|m| m.get(&f.name))
.map(needs_block)
.unwrap_or(false)
})
})
.collect();
if !fields.is_empty() && block.iter().all(|b| *b) {
let candidate = (0..fields.len()).filter(|i| !fields[*i].block).find(|i| {
!records.iter().any(|r| {
matches!(
r.as_object().and_then(|m| m.get(&fields[*i].name)),
Some(Value::String(s))
if s.contains('\n') || s.contains('\r') || s.contains(';')
)
})
});
match candidate {
Some(i) => block[i] = false,
None => {
return Err(SynxlError::new(
SynxlErrorKind::Unwritable,
0,
"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",
))
}
}
}
out.push_str("!fields ");
for (i, field) in fields.iter().enumerate() {
if i > 0 {
out.push_str("; ");
}
write_field_decl(out, field, block[i]);
}
out.push('\n');
for record in records {
let map = record.as_object();
let all_null = fields.iter().enumerate().all(|(i, f)| {
block[i]
|| map
.and_then(|m| m.get(&f.name))
.map(Value::is_null)
.unwrap_or(true)
});
if all_null {
out.push(';');
} else {
let mut first = true;
for (i, field) in fields.iter().enumerate() {
if block[i] {
continue;
}
if !first {
out.push_str("; ");
}
first = false;
let value = map.and_then(|m| m.get(&field.name)).unwrap_or(&Value::Null);
write_inline(out, value);
}
}
out.push('\n');
for (i, field) in fields.iter().enumerate() {
if !block[i] {
continue;
}
let value = match map.and_then(|m| m.get(&field.name)) {
Some(v) if !v.is_null() => v,
_ => continue,
};
write_synx_entry(out, &field.name, value, 2, 0);
}
}
Ok(())
}
fn write_field_decl(out: &mut String, field: &FieldDecl, block: bool) {
out.push_str(&field.name);
if !block {
if let Some(ref hint) = field.type_hint {
out.push('(');
out.push_str(hint);
out.push(')');
}
}
let c = &field.constraints;
let mut parts: Vec<String> = Vec::new();
if !block {
if let Some(ref t) = c.type_name {
parts.push(format!("type:{}", t));
}
}
if c.required {
parts.push("required".to_string());
}
if c.readonly {
parts.push("readonly".to_string());
}
if let Some(min) = c.min {
parts.push(format!("min:{}", trim_float(min)));
}
if let Some(max) = c.max {
parts.push(format!("max:{}", trim_float(max)));
}
if let Some(ref p) = c.pattern {
parts.push(format!("pattern:{}", p));
}
if let Some(ref e) = c.enum_values {
parts.push(format!("enum:{}", e.join("|")));
}
if block {
parts.push("block".to_string());
}
if !parts.is_empty() {
out.push('[');
out.push_str(&parts.join(", "));
out.push(']');
}
}
fn needs_block(value: &Value) -> bool {
match value {
Value::Object(_) | Value::Array(_) => true,
Value::String(s) | Value::Secret(s) => {
s.contains('\n')
|| s.contains('\r')
|| (inline_needs_quote(s) && pick_quote(s).is_none())
}
_ => false,
}
}
fn inline_needs_quote(s: &str) -> bool {
s.is_empty()
|| s != trim_wsp(s)
|| s.contains(';')
|| s.starts_with('#')
|| s.starts_with("//")
|| s.starts_with('!')
|| s.starts_with('"')
|| s.starts_with('\'')
|| cast_inline(s) != Value::String(s.to_string())
}
fn pick_quote(s: &str) -> Option<char> {
if !s.contains('"') {
Some('"')
} else if !s.contains('\'') {
Some('\'')
} else {
None
}
}
fn write_inline(out: &mut String, value: &Value) {
match value {
Value::Null => {}
Value::Bool(b) => out.push_str(if *b { "true" } else { "false" }),
Value::Int(n) => {
let mut buf = itoa::Buffer::new();
out.push_str(buf.format(*n));
}
Value::Float(f) => write_float(out, *f),
Value::String(s) | Value::Secret(s) => {
if inline_needs_quote(s) {
match pick_quote(s) {
Some(q) => {
out.push(q);
out.push_str(s);
out.push(q);
}
None => out.push_str(s),
}
} else {
out.push_str(s);
}
}
Value::Object(_) | Value::Array(_) => {}
}
}
fn write_float(out: &mut String, f: f64) {
if !f.is_finite() {
return;
}
let s = f.to_string();
if !s.contains('e') && !s.contains('E') {
out.push_str(&s);
if !s.contains('.') {
out.push_str(".0");
}
return;
}
let expanded = format!("{:.*}", FLOAT_EXPANSION_PRECISION, f);
let trimmed = expanded.trim_end_matches('0');
let trimmed = if trimmed.ends_with('.') { &expanded[..trimmed.len() + 1] } else { trimmed };
out.push_str(trimmed);
}
fn trim_float(f: f64) -> String {
let s = f.to_string();
s.strip_suffix(".0").map(|s| s.to_string()).unwrap_or(s)
}
fn write_synx_entry(out: &mut String, key: &str, value: &Value, indent: usize, depth: usize) {
if depth > MAX_WRITE_DEPTH {
return;
}
match value {
Value::Object(map) => {
push_indent(out, indent);
out.push_str(key);
out.push('\n');
let mut keys: Vec<&String> = map.keys().collect();
keys.sort_unstable();
for k in keys {
write_synx_entry(out, k, &map[k], indent + 2, depth + 1);
}
}
Value::Array(items) => {
push_indent(out, indent);
out.push_str(key);
if items.is_empty() {
out.push_str(":join");
}
out.push('\n');
for item in items {
write_synx_item(out, item, indent + 2, depth + 1);
}
}
Value::String(s) if s.contains('\n') => write_synx_multiline(out, key, s, indent),
_ => {
let scalar = synx_scalar(value);
match scalar {
Some(text) => {
push_indent(out, indent);
out.push_str(key);
out.push(' ');
out.push_str(&text);
out.push('\n');
}
None => {
let s = value.as_str().unwrap_or_default().to_string();
write_synx_multiline(out, key, &s, indent);
}
}
}
}
}
fn write_synx_item(out: &mut String, item: &Value, indent: usize, depth: usize) {
if depth > MAX_WRITE_DEPTH {
return;
}
match item {
Value::Object(map) => {
let mut keys: Vec<&String> = map.keys().collect();
keys.sort_unstable();
let head = keys
.iter()
.position(|k| dash_line_safe(&map[*k]))
.unwrap_or(0);
let head_key = keys[head];
push_indent(out, indent);
out.push_str("- ");
out.push_str(head_key);
match synx_scalar(&map[head_key]) {
Some(text) if !text.is_empty() => {
out.push(' ');
out.push_str(&text);
}
_ => {}
}
out.push('\n');
for (i, k) in keys.iter().enumerate() {
if i == head {
continue;
}
write_synx_entry(out, k, &map[*k], indent + 2, depth + 1);
}
}
_ => {
push_indent(out, indent);
out.push_str("- ");
match synx_scalar(item) {
Some(text) => out.push_str(&text),
None => out.push_str(item.as_str().unwrap_or_default()),
}
out.push('\n');
}
}
}
fn dash_line_safe(value: &Value) -> bool {
match value {
Value::Object(map) => map.is_empty(),
Value::Array(_) => false,
Value::String(s) => !s.contains('\n') && synx_scalar(value).is_some() && !s.is_empty(),
_ => true,
}
}
fn write_synx_multiline(out: &mut String, key: &str, body: &str, indent: usize) {
push_indent(out, indent);
out.push_str(key);
out.push_str(" |+\n");
for line in body.split('\n') {
push_indent(out, indent + 2);
out.push_str(line);
out.push('\n');
}
}
fn synx_scalar(value: &Value) -> Option<String> {
match value {
Value::Null => Some("null".to_string()),
Value::Bool(b) => Some(if *b { "true" } else { "false" }.to_string()),
Value::Int(n) => Some(n.to_string()),
Value::Float(f) => {
if !f.is_finite() {
return Some("null".to_string());
}
let mut s = String::new();
write_float(&mut s, *f);
Some(s)
}
Value::String(s) | Value::Secret(s) => {
if s.contains('\n') || s.contains(" #") || s.contains(" //") {
return None;
}
if !synx_value_needs_quote(s) {
return Some(s.clone());
}
pick_quote(s).map(|q| format!("{}{}{}", q, s, q))
}
Value::Object(_) | Value::Array(_) => None,
}
}
fn synx_value_needs_quote(s: &str) -> bool {
s.is_empty()
|| s != s.trim()
|| s == "|"
|| s == "|+"
|| is_quote_wrapped(s)
|| !matches!(parser::cast(s), Value::String(ref v) if v == s)
}
#[inline]
fn starts_with_wsp(s: &str) -> bool {
s.starts_with(' ') || s.starts_with('\t')
}
#[inline]
fn trim_wsp(s: &str) -> &str {
s.trim_matches(|c| c == ' ' || c == '\t')
}
fn is_quote_wrapped(s: &str) -> bool {
let b = s.as_bytes();
b.len() >= 2
&& ((b[0] == b'"' && b[b.len() - 1] == b'"') || (b[0] == b'\'' && b[b.len() - 1] == b'\''))
}
fn push_indent(out: &mut String, n: usize) {
for _ in 0..n {
out.push(' ');
}
}
fn truncate_utf8(s: &str, max: usize) -> &str {
if s.len() <= max {
return s;
}
let mut end = max;
while end > 0 && !s.is_char_boundary(end) {
end -= 1;
}
&s[..end]
}
fn elide(s: &str) -> String {
const MAX: usize = 48;
if s.len() <= MAX {
return s.to_string();
}
let cut = truncate_utf8(s, MAX);
format!("{}…", cut)
}
#[cfg(test)]
mod tests {
use super::*;
fn doc(src: &str) -> SynxlDocument {
parse_lines(src).expect("expected a successful parse")
}
fn kinds(d: &SynxlDocument) -> Vec<DiagnosticKind> {
d.diagnostics.iter().map(|x| x.kind).collect()
}
#[test]
fn parses_prologue_and_simple_records() {
let d = doc("!synxl 1\n!fields id[type:int] ; name\n1 ; Wario\n2 ; Mario\n");
assert_eq!(d.version, 1);
assert_eq!(d.len(), 2);
assert_eq!(d.to_json(), r#"[{"id":1,"name":"Wario"},{"id":2,"name":"Mario"}]"#);
assert!(d.diagnostics.is_empty());
}
#[test]
fn bom_and_crlf_are_tolerated() {
let d = doc("\u{feff}!synxl 1\r\n!fields a ; b\r\n1 ; 2\r\n");
assert_eq!(d.to_json(), r#"[{"a":1,"b":2}]"#);
}
#[test]
fn comments_and_blank_lines_before_prologue() {
let d = doc("# hi\n\n// there\n###\n!synxl 999\n###\n!synxl 1\n!fields a\nx\n");
assert_eq!(d.to_json(), r#"[{"a":"x"}]"#);
}
#[test]
fn missing_prologue_is_a_hard_error() {
let e = parse_lines("!fields a\nx\n").unwrap_err();
assert_eq!(e.kind, SynxlErrorKind::MissingPrologue);
let e = parse_lines("").unwrap_err();
assert_eq!(e.kind, SynxlErrorKind::MissingPrologue);
let e = parse_lines("!synxl\n!fields a\n").unwrap_err();
assert_eq!(e.kind, SynxlErrorKind::MissingPrologue);
let e = parse_lines("!synxl 1 extra\n!fields a\n").unwrap_err();
assert_eq!(e.kind, SynxlErrorKind::MissingPrologue);
}
#[test]
fn unsupported_version_is_a_hard_error() {
let e = parse_lines("!synxl 2\n!fields a\nx\n").unwrap_err();
assert_eq!(e.kind, SynxlErrorKind::UnsupportedVersion);
let e = parse_lines("!synxl 99999999999999999999\n!fields a\n").unwrap_err();
assert_eq!(e.kind, SynxlErrorKind::UnsupportedVersion);
}
#[test]
fn record_without_field_list_is_a_hard_error() {
let e = parse_lines("!synxl 1\nrogue record\n").unwrap_err();
assert_eq!(e.kind, SynxlErrorKind::NoFieldList);
assert_eq!(e.line, 2);
}
#[test]
fn field_list_can_be_redeclared_mid_document() {
let src = "!synxl 1\n\
!fields id[type:int] ; score[type:float]\n\
1 ; 0.91\n\
# schema evolution\n\
!fields id[type:int] ; score[type:float] ; lang\n\
3 ; 0.55 ; ru\n";
let d = doc(src);
assert_eq!(
d.to_json(),
r#"[{"id":1,"score":0.91},{"id":3,"lang":"ru","score":0.55}]"#
);
assert_eq!(d.field_lists.len(), 2);
assert_eq!(d.record_field_lists, vec![0, 1]);
assert_eq!(d.field_list_for(1).unwrap().arity(), 3);
}
#[test]
fn duplicate_field_name_is_a_hard_error() {
let e = parse_lines("!synxl 1\n!fields a ; b ; a\nx ; y ; z\n").unwrap_err();
assert_eq!(e.kind, SynxlErrorKind::DuplicateField);
}
#[test]
fn marker_chain_is_a_hard_error() {
let e = parse_lines("!synxl 1\n!fields port:env:default:3000\n1\n").unwrap_err();
assert_eq!(e.kind, SynxlErrorKind::MarkerChain);
let e = parse_lines("!synxl 1\n!fields id[required]:env\n1\n").unwrap_err();
assert_eq!(e.kind, SynxlErrorKind::MarkerChain);
let e = parse_lines("!synxl 1\n!fields id:custom\n1\n").unwrap_err();
assert_eq!(e.kind, SynxlErrorKind::MarkerChain);
}
#[test]
fn non_deterministic_hint_is_a_hard_error() {
for src in [
"!synxl 1\n!fields id(random)\n1\n",
"!synxl 1\n!fields id(random:float)\n1\n",
"!synxl 1\n!fields id[type:random:bool]\n1\n",
] {
let e = parse_lines(src).unwrap_err();
assert_eq!(e.kind, SynxlErrorKind::NonDeterministicHint, "{src}");
}
}
#[test]
fn block_with_type_is_a_hard_error() {
let e = parse_lines("!synxl 1\n!fields m(int)[block]\n\n").unwrap_err();
assert_eq!(e.kind, SynxlErrorKind::BlockWithType);
let e = parse_lines("!synxl 1\n!fields m[block, type:int]\n\n").unwrap_err();
assert_eq!(e.kind, SynxlErrorKind::BlockWithType);
}
#[test]
fn malformed_field_lists_are_hard_errors() {
for src in [
"!synxl 1\n!fields\nx\n",
"!synxl 1\n!fields a ; ; b\nx\n",
"!synxl 1\n!fields a[required\nx\n",
"!synxl 1\n!fields a(int\nx\n",
] {
let e = parse_lines(src).unwrap_err();
assert_eq!(e.kind, SynxlErrorKind::MalformedFieldList, "{src}");
}
}
#[test]
fn field_name_length_limit() {
let long = "n".repeat(MAX_SYNXL_FIELD_NAME_BYTES + 1);
let e = parse_lines(&format!("!synxl 1\n!fields {long}\nx\n")).unwrap_err();
assert_eq!(e.kind, SynxlErrorKind::LimitExceeded);
}
#[test]
fn field_count_limit() {
let mut src = String::from("!synxl 1\n!fields ");
for i in 0..(MAX_SYNXL_FIELDS + 1) {
if i > 0 {
src.push_str(" ; ");
}
src.push_str(&format!("f{i}"));
}
src.push('\n');
let e = parse_lines(&src).unwrap_err();
assert_eq!(e.kind, SynxlErrorKind::LimitExceeded);
}
#[test]
fn unrecognised_constraint_parts_are_tolerated() {
let d = doc("!synxl 1\n!fields a[required, futureflag, future:thing]\n5\n");
assert_eq!(d.to_json(), r#"[{"a":5}]"#);
assert!(d.field_lists[0].get("a").unwrap().constraints.required);
}
#[test]
fn arity_ignores_block_fields() {
let d = doc("!synxl 1\n!fields a ; b ; m[block]\n1 ; 2\n");
assert_eq!(d.field_lists[0].arity(), 2);
assert!(d.diagnostics.is_empty());
assert_eq!(d.to_json(), r#"[{"a":1,"b":2,"m":null}]"#);
}
#[test]
fn synx_first_character_filter_does_not_apply() {
let src = "!synxl 1\n!fields v\n-5\n/var/log/app\n@kaiserberg\n[unparsed]\n:marker\n(paren)\n";
let d = doc(src);
assert_eq!(
d.to_json(),
r#"[{"v":-5},{"v":"/var/log/app"},{"v":"@kaiserberg"},{"v":"[unparsed]"},{"v":":marker"},{"v":"(paren)"}]"#
);
}
#[test]
fn reserved_prefixes_at_indent_zero() {
let d = doc("!synxl 1\n!fields v\n//comment\n/data\n# comment\n\"!bang\"\n'#quoted'\n");
assert_eq!(d.to_json(), r##"[{"v":"/data"},{"v":"!bang"},{"v":"#quoted"}]"##);
}
#[test]
fn unknown_bang_line_is_a_hard_error() {
for src in [
"!synxl 1\n!fields a\n1\n!filds a ; b\n2 ; 3\n",
"!synxl 1\n!active\n!fields a\n1\n",
"!synxl 1\n!fields a\n!include /etc/passwd\n1\n",
"!synxl 1\n!fieldsx a\n1\n",
] {
let e = parse_lines(src).unwrap_err();
assert_eq!(e.kind, SynxlErrorKind::UnknownDirective, "{src}");
}
}
#[test]
fn repeated_prologue() {
let d = doc("!synxl 1\n!fields a\n1\n!synxl 1\n!fields a\n2\n");
assert_eq!(d.to_json(), r#"[{"a":1},{"a":2}]"#);
let e = parse_lines("!synxl 1\n!fields a\n1\n!synxl 2\n!fields a\n2\n").unwrap_err();
assert_eq!(e.kind, SynxlErrorKind::UnsupportedVersion);
assert_eq!(e.line, 4);
}
#[test]
fn zero_arity_field_list_is_a_hard_error() {
let e = parse_lines("!synxl 1\n!fields m[block]\n\n m\n k v\n").unwrap_err();
assert_eq!(e.kind, SynxlErrorKind::ZeroArity);
let e = parse_lines("!synxl 1\n!fields a[block] ; b[block]\n").unwrap_err();
assert_eq!(e.kind, SynxlErrorKind::ZeroArity);
}
#[test]
fn quoting_and_the_unquoted_fallback() {
let d = doc(
"!synxl 1\n!fields a ; b ; c\n\
\"has ; semi\" ; 'single' ; \"a\"b\" \n",
);
let rec = d.records[0].as_object().unwrap();
assert_eq!(rec["a"], Value::String("has ; semi".into()));
assert_eq!(rec["b"], Value::String("single".into()));
assert_eq!(rec["c"], Value::String("\"a\"b\"".into()));
}
#[test]
fn unterminated_quote_falls_back_to_unquoted() {
let d = doc("!synxl 1\n!fields a ; b\n\"open ; still\n");
let rec = d.records[0].as_object().unwrap();
assert_eq!(rec["a"], Value::String("\"open".into()));
assert_eq!(rec["b"], Value::String("still".into()));
}
#[test]
fn quoted_values_bypass_casting() {
let d = doc("!synxl 1\n!fields a ; b ; c\n\"42\" ; \"true\" ; \"null\"\n");
assert_eq!(d.to_json(), r#"[{"a":"42","b":"true","c":"null"}]"#);
}
#[test]
fn null_versus_empty_string() {
let d = doc("!synxl 1\n!fields a ; b ; c\n; \"\" ; x\n");
assert_eq!(d.to_json(), r#"[{"a":null,"b":"","c":"x"}]"#);
}
#[test]
fn quoted_part_keeps_interior_whitespace() {
let d = doc("!synxl 1\n!fields a ; b\n\" padded \" ; bare \n");
let rec = d.records[0].as_object().unwrap();
assert_eq!(rec["a"], Value::String(" padded ".into()));
assert_eq!(rec["b"], Value::String("bare".into()));
}
#[test]
fn only_spaces_and_tabs_are_trimmed_from_a_part() {
let d = doc("!synxl 1\n!fields a ; b\nx ; \u{a0}hello\u{a0}\n");
assert_eq!(
d.records[0].as_object().unwrap()["b"],
Value::String("\u{a0}hello\u{a0}".into())
);
let d = doc("!synxl 1\n!fields a\n\u{a0}hello\n");
assert_eq!(d.to_json(), "[]");
assert_eq!(kinds(&d), vec![DiagnosticKind::OrphanBlockLine]);
}
#[test]
fn arity_mismatch_in_both_directions() {
let d = doc("!synxl 1\n!fields a ; b ; c\n1 ; 2\n1 ; 2 ; 3 ; 4 ; 5\n");
assert_eq!(kinds(&d), vec![DiagnosticKind::MissingFields, DiagnosticKind::ExtraFields]);
assert_eq!(d.diagnostics[0].record_index, 0);
assert_eq!(d.diagnostics[0].line, 3);
assert_eq!(d.diagnostics[1].record_index, 1);
assert_eq!(d.diagnostics[1].line, 4);
assert_eq!(d.to_json(), r#"[{"a":1,"b":2,"c":null},{"a":1,"b":2,"c":3}]"#);
}
#[test]
fn all_null_record() {
let d = doc("!synxl 1\n!fields a\n;\n");
assert_eq!(d.to_json(), r#"[{"a":null}]"#);
assert!(d.diagnostics.is_empty());
let d = doc("!synxl 1\n!fields a ; b\n;\n");
assert_eq!(d.to_json(), r#"[{"a":null,"b":null}]"#);
assert!(d.diagnostics.is_empty());
let d = doc("!synxl 1\n!fields a ; b ; c\n;\t\n");
assert_eq!(d.to_json(), r#"[{"a":null,"b":null,"c":null}]"#);
assert!(d.diagnostics.is_empty());
let d = doc("!synxl 1\n!fields a ; m[block]\n;\n m\n k v\n");
assert_eq!(d.to_json(), r#"[{"a":null,"m":{"k":"v"}}]"#);
assert!(d.diagnostics.is_empty());
let d = doc("!synxl 1\n!fields a ; b\n;;\n");
assert_eq!(d.to_json(), r#"[{"a":null,"b":null}]"#);
assert_eq!(kinds(&d), vec![DiagnosticKind::ExtraFields]);
}
#[test]
fn all_null_record_round_trips_at_every_arity() {
for fields in ["a", "a ; b", "a ; b ; c", "a ; b ; m[block]"] {
let src = format!("!synxl 1\n!fields {fields}\n;\n");
let a = doc(&src);
let written = a.to_synxl().unwrap();
assert!(
written.lines().any(|l| l == ";"),
"expected an all-null record line for `{fields}`:\n{written}"
);
assert_eq!(a.to_json(), doc(&written).to_json(), "{written}");
assert!(doc(&written).diagnostics.is_empty(), "{written}");
}
}
#[test]
fn trailing_semicolon_yields_a_part() {
let d = doc("!synxl 1\n!fields a ; b\n1 ;\n");
assert_eq!(d.to_json(), r#"[{"a":1,"b":null}]"#);
assert!(d.diagnostics.is_empty());
}
#[test]
fn inline_comments_are_not_stripped() {
let d = doc("!synxl 1\n!fields text\nsee https://x.dev #hashtag // not a comment\n");
assert_eq!(
d.records[0].as_object().unwrap()["text"],
Value::String("see https://x.dev #hashtag // not a comment".into())
);
}
#[test]
fn automatic_casting() {
let d = doc("!synxl 1\n!fields a ; b ; c ; d ; e ; f\n1 ; -2.5 ; true ; false ; null ; text\n");
assert_eq!(
d.to_json(),
r#"[{"a":1,"b":-2.5,"c":true,"d":false,"e":null,"f":"text"}]"#
);
}
#[test]
fn typed_cast_failure_nulls_the_cell_only() {
let d = doc("!synxl 1\n!fields id[type:int] ; score(float)\nnope ; 0.5\n7 ; nope\n");
assert_eq!(kinds(&d), vec![DiagnosticKind::CastFailed, DiagnosticKind::CastFailed]);
assert_eq!(d.to_json(), r#"[{"id":null,"score":0.5},{"id":7,"score":null}]"#);
}
#[test]
fn typed_bool_and_string() {
let d = doc("!synxl 1\n!fields a[type:bool] ; b(string) ; c[type:bool]\ntrue ; 42 ; yes\n");
assert_eq!(d.to_json(), r#"[{"a":true,"b":"42","c":null}]"#);
assert_eq!(kinds(&d), vec![DiagnosticKind::CastFailed]);
}
#[test]
fn validation_is_opt_in() {
let src = "!synxl 1\n!fields id[type:int, min:10] ; name[required]\n5 ; \n";
let d = doc(src);
assert!(d.diagnostics.is_empty());
let d = parse_lines_with(src, &SynxlOptions { validate: true }).unwrap();
assert_eq!(
kinds(&d),
vec![DiagnosticKind::ConstraintViolation, DiagnosticKind::ConstraintViolation]
);
}
#[test]
fn worked_example_from_the_spec() {
let src = "!synxl 1
!fields id[type:int, required] ; score[type:float] ; messages[block]
1 ; 0.91
messages
- role system
content You are a helpful assistant.
- role user
content |+
def f(x):
return x + 1
2 ; 0.74
messages
- role user
content Привет
";
let d = doc(src);
assert_eq!(d.len(), 2);
assert!(d.diagnostics.is_empty());
let mut expected = String::from(
r#"{"id":1,"messages":[{"content":"You are a helpful assistant.","role":"system"},"#,
);
expected.push_str(r#"{"content":"def f(x):\n return x + 1","role":"user"}],"score":0.91}"#);
let mut json = String::new();
crate::write_json(&mut json, &d.records[0]);
assert_eq!(json, expected);
assert_eq!(
d.to_ndjson().lines().count(),
2,
"NDJSON projection is one object per line"
);
}
#[test]
fn empty_block_yields_null_block_fields() {
let d = doc("!synxl 1\n!fields a ; m[block]\n1\n\n2\n");
assert_eq!(d.to_json(), r#"[{"a":1,"m":null},{"a":2,"m":null}]"#);
}
#[test]
fn blank_lines_do_not_terminate_a_block() {
let d = doc("!synxl 1\n!fields a ; m[block]\n1\n\n m\n x 1\n\n2\n");
assert_eq!(d.to_json(), r#"[{"a":1,"m":{"x":1}},{"a":2,"m":null}]"#);
}
#[test]
fn block_key_diagnostics() {
let d = doc("!synxl 1\n!fields a ; m[block]\n1\n bogus 1\n a 99\n");
assert_eq!(
kinds(&d),
vec![DiagnosticKind::BlockFieldNotDeclared, DiagnosticKind::UnknownBlockKey]
);
assert_eq!(d.diagnostics[0].line, 5);
assert_eq!(d.diagnostics[1].line, 4);
assert!(d.diagnostics.iter().all(|x| x.record_index == 0));
assert_eq!(d.to_json(), r#"[{"a":1,"m":null}]"#);
}
#[test]
fn block_diagnostic_line_survives_nesting_and_multiline() {
let src = "!synxl 1
!fields id ; m[block]
1
m
- role user
content |+
text
zzz here
";
let d = doc(src);
assert_eq!(kinds(&d), vec![DiagnosticKind::UnknownBlockKey]);
assert_eq!(d.diagnostics[0].line, 8);
}
#[test]
fn type_hints_inside_a_cell_are_not_interpreted() {
let d = doc("!synxl 1\n!fields a ; b\n(random) ; (int)5\n");
assert_eq!(d.to_json(), r#"[{"a":"(random)","b":"(int)5"}]"#);
}
#[test]
fn directives_inside_a_block_are_ignored() {
let src = "!synxl 1\n!fields a ; m[block]\n1\n m\n !include /etc/passwd\n k v\n";
let d = doc(src);
assert_eq!(d.to_json(), r#"[{"a":1,"m":{"k":"v"}}]"#);
}
#[test]
fn directives_inside_a_multiline_body_are_preserved() {
let src = "!synxl 1\n!fields a ; m[block]\n1\n m |+\n !include /etc/passwd\n !active\n";
let d = doc(src);
assert_eq!(
d.records[0].as_object().unwrap()["m"],
Value::String("!include /etc/passwd\n!active".into())
);
}
#[test]
fn active_mode_cannot_be_switched_on_from_a_block() {
let src = "!synxl 1\n!fields a ; m[block]\n1\n !active\n m\n tax:calc 2 * 2\n";
let d = doc(src);
assert_eq!(d.to_json(), r#"[{"a":1,"m":{"tax":"2 * 2"}}]"#);
}
#[test]
fn orphan_block_lines_are_reported_against_the_next_record() {
let d = doc("!synxl 1\n!fields a\n stray one\n5\n!fields a\n stray two\n");
assert_eq!(d.to_json(), r#"[{"a":5}]"#);
assert_eq!(
kinds(&d),
vec![DiagnosticKind::OrphanBlockLine, DiagnosticKind::OrphanBlockLine]
);
assert_eq!((d.diagnostics[0].record_index, d.diagnostics[0].line), (0, 3));
assert_eq!((d.diagnostics[1].record_index, d.diagnostics[1].line), (1, 6));
}
#[test]
fn oversized_record_is_truncated_not_rejected() {
let mut src = String::from("!synxl 1\n!fields a ; b\n");
src.push_str("head ; ");
src.push_str(&"x".repeat(MAX_SYNXL_RECORD_BYTES + 16));
src.push_str("\ntail ; ok\n");
let d = doc(&src);
assert_eq!(d.len(), 2, "the following record still parses");
assert_eq!(kinds(&d), vec![DiagnosticKind::RecordTruncated]);
assert_eq!(d.diagnostics[0].record_index, 0);
let b = d.records[0].as_object().unwrap()["b"].as_str().unwrap();
assert!(b.len() < MAX_SYNXL_RECORD_BYTES);
assert_eq!(d.records[1].as_object().unwrap()["a"], Value::String("tail".into()));
}
#[test]
fn field_list_count_limit() {
let mut src = String::from("!synxl 1\n");
for _ in 0..(MAX_SYNXL_FIELD_LISTS + 1) {
src.push_str("!fields a\n");
}
let e = parse_lines(&src).unwrap_err();
assert_eq!(e.kind, SynxlErrorKind::LimitExceeded);
}
#[test]
fn truncation_respects_utf8_boundaries() {
let s = "aя";
assert_eq!(truncate_utf8(s, 2), "a");
assert_eq!(truncate_utf8(s, 3), "aя");
assert_eq!(truncate_utf8(s, 99), "aя");
assert_eq!(truncate_utf8("😀", 3), "");
}
#[test]
fn streaming_and_whole_document_agree() {
let src = "!synxl 1
!fields id[type:int] ; note ; m[block]
1 ; ok
m
k v
2 ; oops ; surplus
!fields id[type:int] ; note
3
";
let whole = doc(src);
let mut records = Vec::new();
let mut diagnostics = Vec::new();
for item in SynxlReader::new(src).unwrap() {
let mut rec = item.unwrap();
records.push(rec.value);
diagnostics.append(&mut rec.diagnostics);
}
assert_eq!(whole.to_json(), records_to_json_array(&records));
assert_eq!(whole.diagnostics, diagnostics);
assert_eq!(
kinds(&whole),
vec![DiagnosticKind::ExtraFields, DiagnosticKind::MissingFields]
);
}
#[test]
fn a_document_may_exceed_the_synx_input_cap() {
let mut src = String::from("!synxl 1\n!fields a ; b\n");
let row = "0123456789 ; abcdefghij\n";
let rows = (17 * 1024 * 1024) / row.len() + 1;
src.reserve(rows * row.len());
for _ in 0..rows {
src.push_str(row);
}
assert!(src.len() > 16 * 1024 * 1024);
let d = doc(&src);
assert_eq!(d.len(), rows);
assert!(d.diagnostics.is_empty());
}
#[test]
fn streaming_reader_yields_records_incrementally() {
let src = "!synxl 1\n!fields a ; m[block]\n1\n m\n k v\n2 ; ignored\n";
let mut reader = SynxlReader::new(src).unwrap();
let first = reader.next().unwrap().unwrap();
assert_eq!(first.index, 0);
assert_eq!(first.line, 3);
assert!(first.diagnostics.is_empty());
let second = reader.next().unwrap().unwrap();
assert_eq!(second.index, 1);
assert_eq!(second.line, 6);
assert_eq!(second.diagnostics.len(), 1);
assert_eq!(second.diagnostics[0].kind, DiagnosticKind::ExtraFields);
assert!(reader.next().is_none());
}
#[test]
fn owned_reader_matches_the_borrowing_one() {
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";
let borrowed: Vec<SynxlRecord> = SynxlReader::new(src)
.unwrap()
.map(Result::unwrap)
.collect();
let mut owned = {
let moved = String::from(src);
SynxlReaderOwned::new(moved).unwrap()
};
assert_eq!(owned.version(), 1);
let mut collected = Vec::new();
while let Some(item) = owned.next() {
collected.push(item.unwrap());
}
assert_eq!(collected, borrowed);
assert_eq!(collected.len(), 2);
assert_eq!(collected[1].line, 6, "BOM and CRLF must not shift line numbers");
assert_eq!(owned.field_lists().len(), 1);
assert_eq!(owned.text(), src);
}
#[test]
fn owned_reader_is_returnable_from_a_function() {
fn open(src: &str) -> SynxlReaderOwned {
SynxlReaderOwned::with_options(src.to_string(), SynxlOptions { validate: true })
.unwrap()
}
let mut reader = open("!synxl 1\n!fields a[required]\n;\n");
let rec = reader.next().unwrap().unwrap();
assert_eq!(rec.diagnostics.len(), 1);
assert_eq!(rec.diagnostics[0].kind, DiagnosticKind::ConstraintViolation);
assert!(reader.next().is_none());
assert_eq!(reader.into_text().lines().count(), 3);
}
#[test]
fn owned_reader_reports_the_prologue_error_eagerly() {
let err = SynxlReaderOwned::new("nope\n".to_string()).unwrap_err();
assert_eq!(err.kind, SynxlErrorKind::MissingPrologue);
}
fn all_three_agree(src: &str) -> Vec<SynxlRecord> {
let borrowed: Vec<SynxlRecord> =
SynxlReader::new(src).unwrap().map(Result::unwrap).collect();
let owned: Vec<SynxlRecord> = SynxlReaderOwned::new(src.to_string())
.unwrap()
.map(Result::unwrap)
.collect();
let streamed: Vec<SynxlRecord> = SynxlStreamReader::new(std::io::Cursor::new(src))
.unwrap()
.map(Result::unwrap)
.collect();
assert_eq!(owned, borrowed, "owned reader diverged");
assert_eq!(streamed, borrowed, "io reader diverged");
borrowed
}
#[test]
fn io_streaming_matches_the_in_memory_readers() {
let recs = all_three_agree(
"\u{feff}!synxl 1\r
!fields id[type:int] ; note ; m[block]\r
\r
1 ; ok\r
m\r
- role user\r
content |+\r
line one\r
\r
line two\r
2 ; oops ; surplus\r
bogus 1\r
!fields id[type:int]\r
orphan\r
;\r
",
);
assert_eq!(recs.len(), 3);
assert_eq!(recs[0].line, 4, "BOM and CRLF must not shift line numbers");
assert_eq!(recs[1].diagnostics.len(), 2);
assert_eq!(recs[2].value.as_object().unwrap()["id"], Value::Null);
}
#[test]
fn io_streaming_agrees_on_blocks_comments_and_diagnostics() {
all_three_agree(
"!synxl 1
###
!fields hidden
###
!fields a ; b ; m[block]
# comment
1 ; 2
m
k v
j w
//comment
3 ; 4 ; 5
m
!include /etc/passwd
q |+
!active
;
",
);
}
#[test]
fn io_streaming_bounds_memory_on_an_oversized_record() {
let mut src = String::from("!synxl 1\n!fields a ; b\n");
src.push_str("head ; ");
src.push_str(&"x".repeat(MAX_SYNXL_RECORD_BYTES + 4096));
src.push_str("\ntail ; ok\n");
let streamed: Vec<SynxlRecord> = SynxlStreamReader::new(std::io::Cursor::new(&src))
.unwrap()
.map(Result::unwrap)
.collect();
let borrowed: Vec<SynxlRecord> =
SynxlReader::new(&src).unwrap().map(Result::unwrap).collect();
assert_eq!(streamed.len(), 2, "the record after the oversized one survives");
assert_eq!(streamed[0].diagnostics[0].kind, DiagnosticKind::RecordTruncated);
assert_eq!(
streamed[0].value.as_object().unwrap()["b"],
borrowed[0].value.as_object().unwrap()["b"],
"both readers must cut at the same byte"
);
assert_eq!(streamed[1].value, borrowed[1].value);
}
#[test]
fn io_streaming_bounds_memory_on_an_oversized_block() {
let mut src = String::from("!synxl 1\n!fields a ; m[block]\n1\n m |+\n");
let line = format!(" {}\n", "y".repeat(4095));
for _ in 0..((MAX_SYNXL_RECORD_BYTES / line.len()) + 2) {
src.push_str(&line);
}
src.push_str("2\n");
let streamed: Vec<SynxlRecord> = SynxlStreamReader::new(std::io::Cursor::new(&src))
.unwrap()
.map(Result::unwrap)
.collect();
let borrowed: Vec<SynxlRecord> =
SynxlReader::new(&src).unwrap().map(Result::unwrap).collect();
assert_eq!(streamed.len(), 2);
assert_eq!(streamed[0].diagnostics[0].kind, DiagnosticKind::RecordTruncated);
assert_eq!(streamed[0].value, borrowed[0].value);
assert_eq!(streamed[1].value, borrowed[1].value);
}
#[test]
fn io_streaming_survives_a_line_without_a_newline() {
let mut src = String::from("!synxl 1\n!fields a\n");
src.push_str(&"z".repeat(MAX_SYNXL_RECORD_BYTES + 1024));
let recs: Vec<SynxlRecord> = SynxlStreamReader::new(std::io::Cursor::new(&src))
.unwrap()
.map(Result::unwrap)
.collect();
assert_eq!(recs.len(), 1);
assert_eq!(recs[0].diagnostics[0].kind, DiagnosticKind::RecordTruncated);
let a = recs[0].value.as_object().unwrap()["a"].as_str().unwrap();
assert_eq!(a.len(), MAX_SYNXL_RECORD_BYTES);
}
#[test]
fn io_streaming_separates_format_errors_from_io_errors() {
let err = SynxlStreamReader::new(std::io::Cursor::new("nope\n")).unwrap_err();
assert_eq!(err.as_format().unwrap().kind, SynxlErrorKind::MissingPrologue);
assert!(err.as_io().is_none());
let mut reader =
SynxlStreamReader::new(std::io::Cursor::new("!synxl 1\n!fields a\n1\n!filds a\n2\n"))
.unwrap();
assert!(reader.next().unwrap().is_ok());
let err = reader.next().unwrap().unwrap_err();
assert_eq!(err.as_format().unwrap().kind, SynxlErrorKind::UnknownDirective);
assert!(reader.next().is_none(), "iteration stops after a hard error");
let bad = [b'!', b's', b'y', b'n', b'x', b'l', b' ', b'1', b'\n', 0xff, 0xfe, b'\n'];
let mut reader = SynxlStreamReader::new(std::io::Cursor::new(&bad[..])).unwrap();
let err = reader.next().unwrap().unwrap_err();
assert!(err.as_format().is_none());
assert_eq!(err.as_io().unwrap().kind(), std::io::ErrorKind::InvalidData);
}
#[test]
fn io_streaming_reports_a_failing_source() {
#[derive(Debug)]
struct Boom;
impl std::io::Read for Boom {
fn read(&mut self, _: &mut [u8]) -> std::io::Result<usize> {
Err(std::io::Error::new(std::io::ErrorKind::BrokenPipe, "boom"))
}
}
let err = SynxlStreamReader::new(std::io::BufReader::new(Boom)).unwrap_err();
assert_eq!(err.as_io().unwrap().kind(), std::io::ErrorKind::BrokenPipe);
}
#[test]
fn field_list_keeps_its_source_line() {
let d = doc("!synxl 1\n!fields id[type:int] ; name \n1 ; x\n!fields a\n2\n");
assert_eq!(d.field_lists[0].source(), "!fields id[type:int] ; name");
assert_eq!(d.field_lists[0].line, 2);
assert_eq!(d.field_lists[1].source(), "!fields a");
assert_eq!(d.field_lists[1].line, 4);
assert_eq!(FieldList::new(vec![FieldDecl::new("a")]).source(), "");
let mut reader = SynxlReader::new("!synxl 1\n!fields a ; b\n1 ; 2\n").unwrap();
reader.next();
assert_eq!(reader.field_list().unwrap().source(), "!fields a ; b");
}
#[test]
fn streaming_reader_reports_a_mid_document_hard_error_once() {
let src = "!synxl 1\n!fields a\n1\n!fields a ; a\n2\n";
let mut reader = SynxlReader::new(src).unwrap();
assert!(reader.next().unwrap().is_ok());
let err = reader.next().unwrap().unwrap_err();
assert_eq!(err.kind, SynxlErrorKind::DuplicateField);
assert!(reader.next().is_none(), "iteration stops after a hard error");
}
fn round_trip(src: &str) {
let a = doc(src);
let written = a.to_synxl().unwrap();
let b = doc(&written);
assert_eq!(
a.to_json(),
b.to_json(),
"round-trip mismatch\n--- written ---\n{written}"
);
}
#[test]
fn round_trip_scalars_and_quoting() {
round_trip(
"!synxl 1\n!fields a ; b ; c ; d ; e ; f ; g\n\
1 ; \"42\" ; \"has ; semi\" ; ; \"\" ; -2.5 ; true\n",
);
}
#[test]
fn round_trip_reserved_prefixes_and_padding() {
round_trip(
"!synxl 1\n!fields a ; b ; c ; d\n\
\"#tag\" ; \"//path\" ; \"!bang\" ; \" padded \"\n",
);
}
#[test]
fn round_trip_blocks_and_schema_evolution() {
round_trip(
"!synxl 1
!fields id[type:int, required] ; score[type:float] ; messages[block]
1 ; 0.91
messages
- role system
content You are a helpful assistant.
- role user
content |+
def f(x):
return x + 1
!fields id[type:int] ; lang ; messages[block]
3 ; ru
messages
- role user
content Как дела?
",
);
}
#[test]
fn writer_promotes_multiline_values_to_a_block() {
let fields = vec![FieldDecl::new("id"), FieldDecl::new("text")];
let mut rec = HashMap::new();
rec.insert("id".to_string(), Value::Int(1));
rec.insert("text".to_string(), Value::String("line one\n line two".into()));
let records = vec![Value::Object(rec)];
let out = write_lines(&fields, &records).unwrap();
assert!(out.contains("text[block]"), "{out}");
assert!(out.contains("|+"), "{out}");
let back = doc(&out);
assert_eq!(back.to_json(), records_to_json_array(&records));
}
#[test]
fn writer_promotes_values_that_cannot_be_quoted() {
let fields = vec![FieldDecl::new("id"), FieldDecl::new("a")];
let mut rec = HashMap::new();
rec.insert("id".to_string(), Value::Int(1));
rec.insert("a".to_string(), Value::String("mix \" and ' and ; here".into()));
let records = vec![Value::Object(rec)];
let out = write_lines(&fields, &records).unwrap();
assert!(out.contains("a[block]"), "{out}");
let back = doc(&out);
assert_eq!(back.to_json(), records_to_json_array(&records));
}
#[test]
fn writer_rejects_an_unwritable_value() {
let fields = vec![FieldDecl::new("a")];
let mut rec = HashMap::new();
rec.insert("a".to_string(), Value::String("mix \" and ' and ; here".into()));
let records = vec![Value::Object(rec)];
let err = write_lines(&fields, &records).unwrap_err();
assert_eq!(err.kind, SynxlErrorKind::Unwritable);
let doc = SynxlDocument {
version: 1,
records,
field_lists: vec![FieldList::new(fields)],
record_field_lists: vec![0],
record_lines: vec![3],
diagnostics: Vec::new(),
};
assert_eq!(doc.to_synxl().unwrap_err().kind, SynxlErrorKind::Unwritable);
}
#[test]
fn writer_keeps_one_column_inline_instead_of_zero_arity() {
let fields = vec![FieldDecl::new("a")];
let mut rec = HashMap::new();
rec.insert("a".to_string(), Value::String("'x\"y".into()));
let records = vec![Value::Object(rec)];
let out = write_lines(&fields, &records).unwrap();
assert!(!out.contains("[block]"), "{out}");
let back = doc(&out);
assert_eq!(back.to_json(), records_to_json_array(&records));
}
#[test]
fn writer_output_is_a_valid_document_without_records() {
let d = doc("!synxl 1\n!fields a ; b\n");
assert_eq!(d.to_synxl().unwrap(), "!synxl 1\n!fields a; b\n");
assert_eq!(doc(&d.to_synxl().unwrap()).to_json(), "[]");
}
#[test]
fn write_lines_emits_the_canonical_shape() {
let d = doc("!synxl 1\n!fields id[type:int] ; name\n1 ; Wario\n");
let out = d.to_synxl().unwrap();
assert_eq!(out, "!synxl 1\n!fields id[type:int]; name\n1; Wario\n");
}
#[test]
fn float_round_trips_through_exponent_form() {
let fields = vec![FieldDecl::new("a"), FieldDecl::new("b"), FieldDecl::new("c")];
let mut rec = HashMap::new();
rec.insert("a".to_string(), Value::Float(1e300));
rec.insert("b".to_string(), Value::Float(5.0));
rec.insert("c".to_string(), Value::Float(f64::NAN));
let records = vec![Value::Object(rec)];
let out = write_lines(&fields, &records).unwrap();
let back = doc(&out);
assert_eq!(back.to_json(), records_to_json_array(&records));
}
#[test]
fn ndjson_projection() {
let d = doc("!synxl 1\n!fields a ; b\n1 ; x\n2 ; y\n");
assert_eq!(d.to_ndjson(), "{\"a\":1,\"b\":\"x\"}\n{\"a\":2,\"b\":\"y\"}\n");
}
#[test]
fn keys_are_sorted_in_the_projection() {
let d = doc("!synxl 1\n!fields z ; a ; m\n1 ; 2 ; 3\n");
assert_eq!(d.to_json(), r#"[{"a":2,"m":3,"z":1}]"#);
}
#[test]
fn splitter_edge_cases() {
let parts: Vec<Part> = PartSplitter::new("a;b").collect();
assert_eq!(parts.len(), 3 - 1);
let parts: Vec<Part> = PartSplitter::new("").collect();
assert_eq!(parts, vec![Part { text: "", quoted: false }]);
let parts: Vec<Part> = PartSplitter::new("'x' ; \"y\"").collect();
assert_eq!(
parts,
vec![
Part { text: "x", quoted: true },
Part { text: "y", quoted: true }
]
);
let parts: Vec<Part> = PartSplitter::new("'x'y ; z").collect();
assert_eq!(parts[0], Part { text: "'x'y", quoted: false });
let parts: Vec<Part> = PartSplitter::new("\"a;b\"").collect();
assert_eq!(parts, vec![Part { text: "a;b", quoted: true }]);
}
}