Skip to main content

synx_core/
synxl.rs

1//! SYNXL — "SYNX Lines": the record-stream counterpart of JSONL and CSV.
2//!
3//! Reference implementation of `docs/spec/SYNXL-1-NORMATIVE.md` (format
4//! version 1), which embeds the SYNX 3.7 language for the block portion of a
5//! record. Section references in this file (`§7.1`, `§9.4`, …) are to that
6//! document unless prefixed with `SYNX`.
7//!
8//! Three entry points:
9//!
10//! * [`parse_lines`] — whole-document parse into a [`SynxlDocument`].
11//! * [`SynxlReader`] — streaming `Iterator` over records (§15.1).
12//! * [`write_document`] / [`write_lines`] — canonical serialization (§14).
13//!
14//! ```rust
15//! use synx_core::synxl;
16//!
17//! let doc = synxl::parse_lines("!synxl 1\n!fields id[type:int] ; name\n1 ; Wario\n").unwrap();
18//! assert_eq!(doc.to_json(), r#"[{"id":1,"name":"Wario"}]"#);
19//! ```
20
21use std::collections::HashMap;
22use std::fmt;
23
24use memchr::memchr;
25
26use crate::parser::{self, ParserOptions};
27use crate::value::{Constraints, Value};
28
29// ─── §13 Resource limits ─────────────────────────────────────
30
31/// SYNXL format version implemented by this crate (§1.3).
32pub const SYNXL_VERSION: u32 = 1;
33
34/// Per **record** (record line plus block). An oversized record is truncated
35/// at a valid UTF-8 boundary and reported as [`DiagnosticKind::RecordTruncated`]
36/// — never a hard error, because one pathological row must not invalidate a
37/// multi-gigabyte dataset (§11.1).
38pub const MAX_SYNXL_RECORD_BYTES: usize = 16 * 1024 * 1024;
39
40/// Fields per field list. Exceeding it is a hard error.
41pub const MAX_SYNXL_FIELDS: usize = 4_096;
42
43/// Field-name length in UTF-8 bytes. Exceeding it is a hard error.
44pub const MAX_SYNXL_FIELD_NAME_BYTES: usize = 255;
45
46/// Field lists per document. Exceeding it is a hard error.
47pub const MAX_SYNXL_FIELD_LISTS: usize = 65_536;
48
49/// Records per **in-memory** parse. [`SynxlReader`] has no record-count limit.
50pub const MAX_SYNXL_RECORDS: usize = 16_777_216;
51
52/// Nesting depth guard for the block writer (§14) — mirrors the crate's other
53/// serializers, which all cap recursion rather than trusting the value tree.
54const MAX_WRITE_DEPTH: usize = 64;
55
56/// Decimal places used when a float has to be expanded out of exponent form so
57/// that it survives a SYNX cast round-trip (see `write_float`). Large enough to
58/// spell out the smallest subnormal `f64` exactly.
59const FLOAT_EXPANSION_PRECISION: usize = 1_100;
60
61// ─── §5 Field list ───────────────────────────────────────────
62
63/// One declaration from a `!fields` line: `name(type)[constraints]`.
64#[derive(Debug, Clone, PartialEq)]
65pub struct FieldDecl {
66    /// Field name. Compared by exact Unicode scalar sequence (§5.1).
67    pub name: String,
68    /// The `(type)` production only. `type:<name>` lives in `constraints`;
69    /// use [`FieldDecl::type_name`] for the effective hint (§8.2).
70    pub type_hint: Option<String>,
71    /// Parsed by the SYNX constraint parser (§5.2). Not enforced unless the
72    /// caller opts into validating mode (§8.4).
73    pub constraints: Constraints,
74    /// The SYNXL-only `block` flag (§5.3).
75    pub block: bool,
76}
77
78impl FieldDecl {
79    /// A plain, untyped, unconstrained field.
80    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    /// The same, flagged `[block]` (§5.3).
90    pub fn new_block(name: impl Into<String>) -> Self {
91        Self { block: true, ..Self::new(name) }
92    }
93
94    /// Effective type hint: `(type)` wins over `type:<name>` (§8.2).
95    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/// The ordered fields in effect for a run of records (§5).
103#[derive(Debug, Clone, PartialEq)]
104pub struct FieldList {
105    fields: Vec<FieldDecl>,
106    arity: usize,
107    source: String,
108    /// 1-based source line of the `!fields` line (0 when synthesised).
109    pub line: usize,
110}
111
112impl FieldList {
113    /// Build a field list, precomputing its arity.
114    ///
115    /// No validation is performed here; [`parse_lines`] rejects duplicate
116    /// names and the other §11.1 conditions while reading the `!fields` line.
117    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    /// The `!fields` line exactly as it appeared in the document, trimmed of
123    /// surrounding whitespace — empty for a programmatically built list.
124    ///
125    /// A tool that splits or concatenates documents (§15.3) has to re-emit the
126    /// field list in effect at the split point; replaying the original bytes
127    /// keeps the shards byte-identical to the source, which re-deriving the
128    /// declaration from [`FieldList::fields`] would not (§14 normalises the
129    /// separator to `; ` and drops the author's spacing).
130    pub fn source(&self) -> &str {
131        &self.source
132    }
133
134    /// Declarations in source order, block fields included.
135    pub fn fields(&self) -> &[FieldDecl] {
136        &self.fields
137    }
138
139    /// Number of fields **without** the `block` flag — the expected count of
140    /// inline parts per record (§5.3).
141    pub fn arity(&self) -> usize {
142        self.arity
143    }
144
145    /// Look a declaration up by exact name.
146    pub fn get(&self, name: &str) -> Option<&FieldDecl> {
147        self.fields.iter().find(|f| f.name == name)
148    }
149}
150
151// ─── §11 Error model ─────────────────────────────────────────
152
153/// Hard-error conditions (§11.1). Every one of these aborts the parse; no
154/// partial result is produced.
155#[derive(Debug, Clone, Copy, PartialEq, Eq)]
156pub enum SynxlErrorKind {
157    /// Missing or malformed `!synxl <version>` prologue (§4.1).
158    MissingPrologue,
159    /// Prologue declares a version this implementation does not support, or a
160    /// repeated prologue declares a different version (§1.3, §4.1). For format
161    /// version 1 the two conditions coincide: "different" implies "not 1".
162    UnsupportedVersion,
163    /// A line at indent 0 beginning with `!` that is neither a prologue nor a
164    /// field list — including SYNX directives such as `!active` (§4.1).
165    UnknownDirective,
166    /// Record line encountered while no field list is in effect (§4.2).
167    NoFieldList,
168    /// Empty or unparsable `!fields` line (§5).
169    MalformedFieldList,
170    /// Duplicate field name within one field list (§5.1).
171    DuplicateField,
172    /// A marker run — chain (`:a:b`) or single (`:custom`) — in a field
173    /// declaration (§5.2).
174    MarkerChain,
175    /// `random` / `random:int` / `random:float` / `random:bool` hint (§8.3).
176    NonDeterministicHint,
177    /// `block` combined with `(type)` or `type:` (§5.3).
178    BlockWithType,
179    /// A field list whose every field carries `block`, i.e. arity 0 (§5.3.4).
180    ZeroArity,
181    /// **Writer only.** A value has no SYNXL rendering: it must be promoted to
182    /// a block (§14.3) but promoting it would leave the field list at arity 0
183    /// (§5.3.4). §14.3 requires rejecting such a value loudly — emitting a
184    /// document that reads back differently, or one that no reader accepts, is
185    /// not permitted. Cannot arise for a value obtained by parsing (§14.1).
186    Unwritable,
187    /// A §13 limit other than `MAX_SYNXL_RECORD_BYTES` was exceeded.
188    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/// A hard error (§11.1) with the 1-based source line that produced it.
217#[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    /// Build an error.
226    ///
227    /// Public because wrappers around this crate — language bindings in
228    /// particular — need to raise the same error type for conditions they
229    /// enforce themselves (a writer rejecting `block` combined with a type,
230    /// say) instead of assembling the struct literally.
231    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/// Recoverable parse observations (§11.2). Reported, never dropped silently.
245#[derive(Debug, Clone, Copy, PartialEq, Eq)]
246pub enum DiagnosticKind {
247    /// Fewer inline parts than the arity; trailing fields set to `null` (§7.3).
248    MissingFields,
249    /// More inline parts than the arity; the surplus was discarded (§7.3).
250    ExtraFields,
251    /// Typed casting failed; the field was set to `null` (§8.2).
252    CastFailed,
253    /// A block key that matches no declared field (§9.3).
254    UnknownBlockKey,
255    /// A block key matching a field that is **not** declared `[block]` (§9.3).
256    BlockFieldNotDeclared,
257    /// A line at indent > 0 where no record is open — for example right after
258    /// a field list. Discarded, and attached to the *following* record (§11.2).
259    OrphanBlockLine,
260    /// A declared constraint was violated — validating mode only (§8.4).
261    ConstraintViolation,
262    /// The record exceeded `MAX_SYNXL_RECORD_BYTES` and was truncated (§13).
263    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/// One diagnostic: record index (0-based), source line (1-based), kind, and a
288/// human-readable message (§11.2).
289#[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// ─── Options ─────────────────────────────────────────────────
308
309/// Parse options. Defaults follow §8.4: validation is opt-in.
310#[derive(Debug, Clone, Default)]
311pub struct SynxlOptions {
312    /// Enforce declared constraints and report violations as
313    /// [`DiagnosticKind::ConstraintViolation`] (§8.4).
314    pub validate: bool,
315}
316
317// ─── Parse results ───────────────────────────────────────────
318
319/// One record produced by [`SynxlReader`].
320#[derive(Debug, Clone, PartialEq)]
321pub struct SynxlRecord {
322    /// 0-based position in the document.
323    pub index: usize,
324    /// 1-based source line of the record line.
325    pub line: usize,
326    /// Index into [`SynxlReader::field_lists`] of the list in effect.
327    pub field_list: usize,
328    /// Always a [`Value::Object`].
329    pub value: Value,
330    /// Diagnostics produced by this record alone (§11.2).
331    pub diagnostics: Vec<Diagnostic>,
332}
333
334/// A fully materialised SYNXL document.
335#[derive(Debug, Clone, PartialEq)]
336pub struct SynxlDocument {
337    /// Format version from the prologue (§4.1).
338    pub version: u32,
339    /// Records in document order; each is a [`Value::Object`].
340    pub records: Vec<Value>,
341    /// Every field list in declaration order (§4.2).
342    pub field_lists: Vec<FieldList>,
343    /// `records[i]` was parsed under `field_lists[record_field_lists[i]]`.
344    pub record_field_lists: Vec<usize>,
345    /// 1-based source line of each record's record line.
346    pub record_lines: Vec<usize>,
347    /// All diagnostics, in record order (§11.2).
348    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    /// The field list that was in effect for record `index`.
361    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    /// Canonical JSON **array** projection (§12.1).
368    pub fn to_json(&self) -> String {
369        records_to_json_array(&self.records)
370    }
371
372    /// Canonical **NDJSON** projection (§12.2).
373    pub fn to_ndjson(&self) -> String {
374        records_to_ndjson(&self.records)
375    }
376
377    /// Canonical SYNXL serialization (§14).
378    ///
379    /// Infallible in practice for a parsed document (§14.1); see
380    /// [`write_document`] for the one condition that rejects.
381    pub fn to_synxl(&self) -> Result<String, SynxlError> {
382        write_document(self)
383    }
384}
385
386/// Canonical JSON array projection of a record slice (§12.1).
387pub 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
400/// Canonical NDJSON projection of a record slice (§12.2): one canonical object
401/// per line, `LF`-separated, no enclosing array.
402pub 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// ─── §15.1 Streaming reader ──────────────────────────────────
412
413/// A single physical line, kept as offsets into the document.
414///
415/// Offsets rather than slices are what let one state machine serve both the
416/// borrowing and the owning reader: the state carries no lifetime, so the
417/// owning reader can keep its `String` beside it without `unsafe`.
418#[derive(Debug, Clone, Copy)]
419struct Line {
420    /// Byte offset of the line's first byte.
421    start: usize,
422    /// Byte offset one past its last byte, `CR` / `LF` excluded.
423    end: usize,
424    /// 1-based line number.
425    no: usize,
426}
427
428impl Line {
429    /// Line content with `CR`/`LF` removed, indentation intact.
430    #[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
442/// What a non-empty line at indent 0 turned out to be (§4.3, §6).
443enum LineKind {
444    /// A comment, a directive, or a field list — already applied, nothing to
445    /// hand back to the caller.
446    Skip,
447    /// A record line; the caller must frame its block and call `build`.
448    Record,
449}
450
451/// One record's text, already framed by whichever line source produced it.
452///
453/// Framing is the only part of reading that differs between an in-memory
454/// document (slices) and an `io::BufRead` (a bounded buffer); everything the
455/// format defines happens in [`ReaderCore::build`], which takes this.
456struct RecordFrame<'t> {
457    /// The record line, `CR`/`LF` excluded.
458    line: &'t str,
459    /// 1-based number of the record line.
460    line_no: usize,
461    /// The block's raw text, original indentation intact, `LF`-joined (§9.3).
462    block: &'t str,
463    /// 1-based number of the block's first line (0 when there is no block).
464    block_first_no: usize,
465    /// Total bytes before truncation, when §13's per-record cap was hit.
466    truncated_total: Option<usize>,
467}
468
469/// The reader state machine, independent of how the document is owned.
470///
471/// Both [`SynxlReader`] (borrowing) and [`SynxlReaderOwned`] (owning) drive
472/// this one type, handing it their text on each call. The parse path is shared
473/// rather than forked, and neither wrapper — nor any binding built on them —
474/// needs `unsafe` to keep a document alive alongside its reader.
475#[derive(Debug)]
476struct ReaderCore {
477    /// Byte offset of the next line to read.
478    pos: usize,
479    /// 1-based number of the next line to read.
480    line_no: usize,
481    /// Look-ahead line pushed back by the block scanner.
482    pending: Option<Line>,
483    /// §11.2 — `OrphanBlockLine` diagnostics found while looking for the next
484    /// record, which they are attached to.
485    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    /// Fresh state, before any input has been seen.
497    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    /// Build the state and validate the prologue eagerly (§4.1).
514    fn start(text: &str, opts: SynxlOptions) -> Result<Self, SynxlError> {
515        let mut core = Self {
516            // §3.1 — a leading U+FEFF is ignored. Skipping it by offset instead
517            // of re-slicing keeps every later offset relative to the caller's
518            // own text, which is what the owning reader indexes into.
519            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    /// Next physical line, honouring the block scanner's push-back.
540    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        // §3.2 — a CR immediately before LF is not part of the line.
554        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    /// One iteration step: the body behind both readers' `Iterator::next`.
564    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                // §11.1 — a hard error ends the document.
576                self.done = true;
577                Some(Err(err))
578            }
579        }
580    }
581
582    /// §4.1 — does the prologue scan skip this line? Toggles `###` state.
583    ///
584    /// Shared by every line source: the prologue must be found the same way
585    /// whether the document is a `&str` or an `io::BufRead`.
586    fn prologue_skip(&mut self, trimmed: &str) -> bool {
587        if trimmed.is_empty() {
588            return true;
589        }
590        // `###` is matched before the `#` line-comment rule (§4.3).
591        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    /// The error raised when input ends before a prologue is found (§4.1).
602    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    /// §4.1 — the first non-empty, non-comment line MUST be the prologue.
611    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    /// §11.2 — record an indented line that has no open record.
624    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    /// Decide what a non-empty line at indent 0 is (§4.3, §6).
634    ///
635    /// This is the whole structural vocabulary of the format in one place, so
636    /// every line source classifies identically; only the byte plumbing around
637    /// it differs.
638    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        // §4.3 — the `!` forms are matched before the comment rules.
648        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                    // §4.1 — a repeated prologue is accepted and ignored when it
659                    // declares the same version (shards are concatenable), and
660                    // rejected when it does not.
661                    parse_prologue(trimmed, no)?;
662                    return Ok(LineKind::Skip);
663                }
664            }
665            // §4.1 — anything else beginning with `!` at indent 0 is a hard
666            // error. Ignoring it would let a typo (`!filds …`) leave the
667            // previous field list in effect and mis-populate every subsequent
668            // record without any signal.
669            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        // §6 — anything else at indent 0 is a record line. The SYNX §7
683        // first-character filter deliberately does NOT apply here.
684        Ok(LineKind::Record)
685    }
686
687    /// The field list in effect, or the §4.2 hard error.
688    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    /// Install a freshly parsed field list, enforcing §13's list cap.
699    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    /// Advance to the next record, consuming structural lines on the way.
713    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            // §3.4 — indent > 0 belongs to the current record's block. Reaching
724            // this loop means no record is open, so the line is orphaned: it is
725            // discarded and reported against the *following* record (§11.2).
726            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            // §9.1 — the block is the maximal run of following lines up to the
737            // next non-empty line at indent 0. Empty lines do not terminate it
738            // (§3.5).
739            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            // Block lines are contiguous in the source, so the whole block is
761            // one slice — no per-line copying, and `|+` base-indent locking
762            // still sees the original indentation (§9.3).
763            let block_raw = match (block_start, block_end) {
764                (Some(s), Some(e)) => &text[s..e],
765                _ => "",
766            };
767
768            // §13 — truncate rather than fail. The record line is kept first:
769            // it carries the inline fields, the more valuable half.
770            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    /// Turn one framed record into a [`SynxlRecord`] (§7, §8, §9, §11.2).
801    ///
802    /// Framing — finding the record line and its block — belongs to the line
803    /// source; everything the format defines happens here, once.
804    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        // Orphan lines physically precede the record they are attached to, so
816        // they lead its diagnostic list (§11.2 orders the other kinds).
817        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        // §9.3 — delegate the block to the SYNX parser *before* borrowing the
831        // field list, so the borrow checker keeps `self` free for the read.
832        let block_root = if block_text.trim().is_empty() {
833            None
834        } else {
835            // §9.4 — directives are disabled inside the embedded parse, and
836            // §9.5 — no `!active` mode means no metadata capture.
837            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            // §7.2 — the all-null record. Matched *before* the §7.1 split,
848            // whose result would otherwise depend on the arity: two null parts
849            // at arity 2, but a spurious `MissingFields` at arity 3. At arity 1
850            // this form is the only representation an all-null record has, an
851            // empty line being invisible (§3.5).
852            for field in fl.fields.iter().filter(|f| !f.block) {
853                obj.insert(field.name.clone(), Value::Null);
854            }
855        } else {
856            // §7 — inline fields, positionally matched against non-block fields.
857            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                    // §7.3 — surplus parts are discarded but still counted.
872                    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        // §9.2 — a block field with no block value is null.
908        for field in fl.fields.iter().filter(|f| f.block) {
909            obj.insert(field.name.clone(), Value::Null);
910        }
911
912        // §11.2 — block diagnostics report the line *inside the block* that
913        // carries the offending key, so the block's key/line map is built the
914        // first time one is needed (never for a clean record).
915        let mut key_lines: Option<HashMap<&str, usize>> = None;
916
917        // §9.3 — match block keys by exact name, visiting them in lexicographic
918        // order so the diagnostic sequence is reproducible despite the HashMap.
919        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        // §8.4 — validation is opt-in.
961        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                        // §11.2 — the record line for an inline field, the
966                        // offending block line for a block field.
967                        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/// Streaming record reader over borrowed text (§15.1).
998///
999/// Record boundaries are decidable from a single byte (§3.4), so records are
1000/// produced incrementally without materialising the document. The prologue is
1001/// validated eagerly by [`SynxlReader::new`]; every later hard error surfaces
1002/// as an `Err` item, after which iteration stops.
1003///
1004/// Use [`SynxlReaderOwned`] when the reader has to outlive the expression that
1005/// produced the text — an FFI wrapper, for instance.
1006///
1007/// ```rust
1008/// use synx_core::synxl::SynxlReader;
1009///
1010/// let src = "!synxl 1\n!fields a ; b\n1 ; 2\n3 ; 4\n";
1011/// let mut n = 0;
1012/// for rec in SynxlReader::new(src).unwrap() {
1013///     assert!(rec.unwrap().diagnostics.is_empty());
1014///     n += 1;
1015/// }
1016/// assert_eq!(n, 2);
1017/// ```
1018#[derive(Debug)]
1019pub struct SynxlReader<'a> {
1020    text: &'a str,
1021    core: ReaderCore,
1022}
1023
1024impl<'a> SynxlReader<'a> {
1025    /// Open a reader, validating the prologue (§4.1).
1026    pub fn new(text: &'a str) -> Result<Self, SynxlError> {
1027        Self::with_options(text, SynxlOptions::default())
1028    }
1029
1030    /// Open a reader with explicit options (§8.4).
1031    pub fn with_options(text: &'a str, opts: SynxlOptions) -> Result<Self, SynxlError> {
1032        Ok(Self { text, core: ReaderCore::start(text, opts)? })
1033    }
1034
1035    /// Declared format version (§4.1).
1036    pub fn version(&self) -> u32 {
1037        self.core.version
1038    }
1039
1040    /// Field lists seen so far, in declaration order.
1041    pub fn field_lists(&self) -> &[FieldList] {
1042        &self.core.field_lists
1043    }
1044
1045    /// The field list currently in effect, if any (§4.2).
1046    pub fn field_list(&self) -> Option<&FieldList> {
1047        self.core.current.map(|i| &self.core.field_lists[i])
1048    }
1049
1050    /// Consume the reader, keeping the field lists it collected.
1051    pub fn into_field_lists(self) -> Vec<FieldList> {
1052        self.core.field_lists
1053    }
1054
1055    /// Diagnostics that were found after the last record and therefore had no
1056    /// record to attach to — `OrphanBlockLine` at end of input (§11.2). Empty
1057    /// until iteration finishes.
1058    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/// Streaming record reader that **owns** its document (§15.1).
1072///
1073/// Identical to [`SynxlReader`] in behaviour and code path — the two share one
1074/// state machine — but it carries the text itself, so it has no lifetime
1075/// parameter and can be stored in a struct, returned from a function, or
1076/// handed to an FFI object without the caller keeping the source alive by hand.
1077/// That is the shape language bindings need, and it removes the only reason
1078/// they would otherwise have had to reach for `unsafe`.
1079///
1080/// ```rust
1081/// use synx_core::synxl::SynxlReaderOwned;
1082///
1083/// fn open() -> SynxlReaderOwned {
1084///     // The `String` is moved into the reader; nothing outlives it.
1085///     SynxlReaderOwned::new(String::from("!synxl 1\n!fields a\n1\n2\n")).unwrap()
1086/// }
1087///
1088/// let records: Vec<_> = open().map(|r| r.unwrap()).collect();
1089/// assert_eq!(records.len(), 2);
1090/// ```
1091#[derive(Debug)]
1092pub struct SynxlReaderOwned {
1093    text: String,
1094    core: ReaderCore,
1095}
1096
1097impl SynxlReaderOwned {
1098    /// Open a reader over an owned document, validating the prologue (§4.1).
1099    pub fn new(text: String) -> Result<Self, SynxlError> {
1100        Self::with_options(text, SynxlOptions::default())
1101    }
1102
1103    /// Open an owning reader with explicit options (§8.4).
1104    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    /// Declared format version (§4.1).
1110    pub fn version(&self) -> u32 {
1111        self.core.version
1112    }
1113
1114    /// Field lists seen so far, in declaration order.
1115    pub fn field_lists(&self) -> &[FieldList] {
1116        &self.core.field_lists
1117    }
1118
1119    /// The field list currently in effect, if any (§4.2).
1120    pub fn field_list(&self) -> Option<&FieldList> {
1121        self.core.current.map(|i| &self.core.field_lists[i])
1122    }
1123
1124    /// Consume the reader, keeping the field lists it collected.
1125    pub fn into_field_lists(self) -> Vec<FieldList> {
1126        self.core.field_lists
1127    }
1128
1129    /// Diagnostics recorded after the last record (§11.2). See
1130    /// [`SynxlReader::trailing_diagnostics`].
1131    pub fn trailing_diagnostics(&self) -> &[Diagnostic] {
1132        &self.core.pending_diagnostics
1133    }
1134
1135    /// The document being read.
1136    pub fn text(&self) -> &str {
1137        &self.text
1138    }
1139
1140    /// Consume the reader and give the document back.
1141    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// ─── §15.1 Streaming from `io::BufRead` ──────────────────────
1155
1156/// What can go wrong while streaming from an [`io::BufRead`].
1157///
1158/// The two arms are kept apart deliberately: a [`SynxlError`] says the document
1159/// is malformed and is the same verdict every conforming implementation must
1160/// reach, while an [`io::Error`] says nothing about the document at all — the
1161/// disk, socket, or pipe failed. Folding the second into the first would make
1162/// a transient network hiccup indistinguishable from a spec violation.
1163#[derive(Debug)]
1164pub enum SynxlStreamError {
1165    /// A §11.1 hard error in the document.
1166    Format(SynxlError),
1167    /// The underlying reader failed, or produced bytes that are not UTF-8 (§3.1).
1168    Io(std::io::Error),
1169}
1170
1171impl SynxlStreamError {
1172    /// The format error, if this is one.
1173    pub fn as_format(&self) -> Option<&SynxlError> {
1174        match self {
1175            SynxlStreamError::Format(e) => Some(e),
1176            SynxlStreamError::Io(_) => None,
1177        }
1178    }
1179
1180    /// The I/O error, if this is one.
1181    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/// Streaming record reader over an [`io::BufRead`] — the reader §15.1 asks for.
1220///
1221/// [`SynxlReader`] and [`SynxlReaderOwned`] stream *records* out of a document
1222/// that is already in memory; this one streams the *document* too. §13
1223/// deliberately removed the whole-file byte cap ("datasets are routinely
1224/// gigabytes"), which only means something if a reader never has to hold the
1225/// file, and §3.4 made record boundaries decidable from a single byte precisely
1226/// so that buffering one record is enough.
1227///
1228/// Live memory is therefore one record — its line plus its block — and
1229/// `MAX_SYNXL_RECORD_BYTES` (§13) is the real bound on it: an oversized record
1230/// is cut at the same byte offset the in-memory readers cut it at, reported
1231/// with the same `RecordTruncated` diagnostic, and the rest of the document
1232/// keeps parsing.
1233///
1234/// Parsing is the same code as the other two readers; only the framing — how
1235/// bytes become a record line and a block — differs.
1236///
1237/// ```rust
1238/// use std::io::Cursor;
1239/// use synx_core::synxl::SynxlStreamReader;
1240///
1241/// let src = Cursor::new("!synxl 1\n!fields a ; b\n1 ; 2\n3 ; 4\n");
1242/// let mut n = 0;
1243/// for rec in SynxlStreamReader::new(src).unwrap() {
1244///     assert!(rec.unwrap().diagnostics.is_empty());
1245///     n += 1;
1246/// }
1247/// assert_eq!(n, 2);
1248/// ```
1249#[derive(Debug)]
1250pub struct SynxlStreamReader<R: std::io::BufRead> {
1251    inner: R,
1252    core: ReaderCore,
1253    /// Byte scratch for the line being read.
1254    scratch: Vec<u8>,
1255    /// The current line, `CR`/`LF` excluded.
1256    cur: String,
1257    /// 1-based number of `cur`.
1258    cur_no: usize,
1259    /// Bytes the §13 cap dropped from `cur`.
1260    cur_dropped: usize,
1261    /// The indent-0 line that ended the previous record's block, pushed back.
1262    lookahead: Option<(String, usize, usize)>,
1263    /// 1-based number of the last line read from `inner`.
1264    line_no: usize,
1265    /// The record being assembled (§13 bounds both buffers).
1266    record: String,
1267    block: String,
1268    /// Blank lines seen inside a block, held back until content follows so a
1269    /// trailing run of them stays out of the block (§9.1).
1270    deferred: String,
1271    done: bool,
1272}
1273
1274impl<R: std::io::BufRead> SynxlStreamReader<R> {
1275    /// Open a streaming reader, validating the prologue (§4.1).
1276    pub fn new(inner: R) -> Result<Self, SynxlStreamError> {
1277        Self::with_options(inner, SynxlOptions::default())
1278    }
1279
1280    /// Open a streaming reader with explicit options (§8.4).
1281    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    /// Declared format version (§4.1).
1301    pub fn version(&self) -> u32 {
1302        self.core.version
1303    }
1304
1305    /// Field lists seen so far, in declaration order.
1306    pub fn field_lists(&self) -> &[FieldList] {
1307        &self.core.field_lists
1308    }
1309
1310    /// The field list currently in effect, if any (§4.2).
1311    pub fn field_list(&self) -> Option<&FieldList> {
1312        self.core.current.map(|i| &self.core.field_lists[i])
1313    }
1314
1315    /// Consume the reader, keeping the field lists it collected.
1316    pub fn into_field_lists(self) -> Vec<FieldList> {
1317        self.core.field_lists
1318    }
1319
1320    /// Diagnostics recorded after the last record (§11.2). See
1321    /// [`SynxlReader::trailing_diagnostics`].
1322    pub fn trailing_diagnostics(&self) -> &[Diagnostic] {
1323        &self.core.pending_diagnostics
1324    }
1325
1326    /// Consume the reader and give the underlying source back.
1327    pub fn into_inner(self) -> R {
1328        self.inner
1329    }
1330
1331    /// Advance `cur` to the next line. `Ok(false)` at end of input.
1332    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        // §13 — a cut must land on a valid UTF-8 boundary; the cap can only
1345        // have split a scalar, so at most three bytes come back off.
1346        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        // §3.1 — a byte order mark at the start of input is ignored.
1362        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    /// Push the current line back so the next `advance` returns it again.
1374    fn push_back(&mut self) {
1375        self.lookahead = Some((std::mem::take(&mut self.cur), self.cur_no, self.cur_dropped));
1376    }
1377
1378    /// §4.1 — the first non-empty, non-comment line MUST be the prologue.
1379    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    /// Advance to the next record, framing it into `record` / `block`.
1394    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            // §3.4 — indent > 0 with no record open is an orphan (§11.2).
1404            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            // §13 — the budget is the record line plus its block, and the line
1418            // is kept first: it carries the inline fields.
1419            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            // §9.1 — the block runs to the next non-empty line at indent 0.
1425            loop {
1426                if !self.advance()? {
1427                    break;
1428                }
1429                if self.cur.trim().is_empty() {
1430                    // §3.5 — blank lines never terminate a block, but a
1431                    // trailing run of them is not part of it either, so they
1432                    // wait here until content proves they were interior.
1433                    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                // Cut at exactly the offset the in-memory readers cut at, so
1449                // the two paths agree byte-for-byte on a truncated record.
1450                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                // §11.1 — a hard error ends the document; an I/O failure means
1492                // there is nothing left to read from either.
1493                self.done = true;
1494                Some(Err(err))
1495            }
1496        }
1497    }
1498}
1499
1500/// Read one line into `out`, keeping at most `cap` bytes of it.
1501///
1502/// The trailing `LF` and a `CR` before it are not stored (§3.2). Returns the
1503/// number of bytes dropped by the cap, or `None` at end of input — which is
1504/// what bounds live memory when a hostile document has no newline in it.
1505fn 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
1557// ─── Whole-document parse ────────────────────────────────────
1558
1559/// Parse a whole SYNXL document (§4–§10).
1560///
1561/// Hard errors (§11.1) are returned as `Err`; recoverable observations live in
1562/// [`SynxlDocument::diagnostics`] (§11.2).
1563pub fn parse_lines(text: &str) -> Result<SynxlDocument, SynxlError> {
1564    parse_lines_with(text, &SynxlOptions::default())
1565}
1566
1567/// [`parse_lines`] with explicit options (§8.4).
1568pub 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        // §13 — the record cap applies to the in-memory parse only.
1582        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    // §11.2 — orphan lines after the last record have no record to attach to,
1596    // but must still be reported.
1597    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
1610/// Map each top-level block key to the 1-based source line that declares it.
1611///
1612/// §11.2 requires `UnknownBlockKey` / `BlockFieldNotDeclared` to report the
1613/// line inside the block, but the SYNX parser returns no positions. The keys
1614/// that end up at the root of the embedded document are those at the block's
1615/// shallowest indent (SYNX §8.6 stack repair), so re-scanning that one level is
1616/// enough — and it only runs when a diagnostic is actually being emitted.
1617fn 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        // Lines SYNX never turns into a key (SYNX §7) plus `!` lines, which
1635        // SYNXL discards inside a block (§9.4).
1636        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
1651// ─── §4.1 Prologue ───────────────────────────────────────────
1652
1653fn 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    // The grammar is `"!synxl" 1*WSP version LF` — nothing may follow.
1666    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
1683// ─── §5 Field-list parsing ───────────────────────────────────
1684
1685/// Parse the text following `!fields` into a [`FieldList`] (§5).
1686///
1687/// `source` is the whole `!fields` line, kept verbatim for tools that re-emit
1688/// it (see [`FieldList::source`]).
1689fn 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        // §5.1 — duplicates are rejected rather than resolved.
1717        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    // §5.3.4 — a zero-arity field list has no representation: its record line
1729    // would be empty, and §3.5 makes empty lines invisible, so records would
1730    // have no detectable boundary.
1731    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
1744/// `name [ "(" type ")" ] [ "[" constraints "]" ]` (§5).
1745fn parse_field_decl(decl: &str, line: usize) -> Result<FieldDecl, SynxlError> {
1746    let bytes = decl.as_bytes();
1747    let len = bytes.len();
1748
1749    // §5.1 — the name runs until `[`, `(`, `:`, or whitespace.
1750    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    // Optional `(type)`.
1779    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    // Optional `[constraints]` — balanced scan, so `pattern:^[A-Z]$` survives.
1798    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        // §5.2 — reuse the SYNX constraint parser verbatim.
1826        constraints = parser::parse_constraints(raw);
1827        // §5.3 — `block` is SYNXL-only, so the SYNX parser drops it as an
1828        // unrecognised bare flag; pick it up here.
1829        block = raw
1830            .split(',')
1831            .map(|p| p.trim())
1832            .any(|p| p == "block");
1833        pos = scan + 1;
1834    }
1835
1836    // Nothing but whitespace may follow.
1837    let tail = decl[pos..].trim();
1838    if !tail.is_empty() {
1839        // §5.2 — marker chains are reserved for a future version.
1840        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    // §8.3 — a dataset whose parse result varies between reads is not
1855    // interchangeable, so the non-deterministic hints are rejected outright.
1856    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    // §5.3.2 — the shape of a block value comes from the embedded document.
1870    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// ─── §7.1 Record-line splitting ──────────────────────────────
1891
1892/// One inline part: the text plus whether it came from a quoted run (§7.4).
1893#[derive(Debug, Clone, Copy, PartialEq)]
1894struct Part<'a> {
1895    text: &'a str,
1896    quoted: bool,
1897}
1898
1899/// Splits a record line on `;` while recognising quotes in the same pass (§7.1).
1900///
1901/// The algorithm is normative and reproduced literally: skip horizontal
1902/// whitespace, try a quoted run, and fall back to an unquoted part on any
1903/// failure — no opening quote, no matching close, or garbage after the close.
1904struct 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        // 1. Skip spaces and horizontal tabs.
1928        while i < len && (self.b[i] == b' ' || self.b[i] == b'\t') {
1929            i += 1;
1930        }
1931
1932        // 2. Quoted candidate.
1933        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                    // §7.4 — the value is the text strictly between the quotes,
1943                    // untrimmed and uninterpreted.
1944                    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        // 3. Unquoted: up to the next `;` or end of line; quotes inside are
1957        //    ordinary content. 4. Trimmed of leading/trailing space and tab.
1958        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
1973// ─── §8 Casting ──────────────────────────────────────────────
1974
1975/// Cast one inline part for `field`, recording a diagnostic on failure.
1976fn cast_part(
1977    field: &FieldDecl,
1978    part: Part<'_>,
1979    record_index: usize,
1980    line: usize,
1981    diagnostics: &mut Vec<Diagnostic>,
1982) -> Value {
1983    // §7.4 — a quoted part is literal inner text: no casting at all.
1984    if part.quoted {
1985        return Value::String(part.text.to_string());
1986    }
1987    // §7.2 — an empty unquoted part is null, not an empty string.
1988    if part.text.is_empty() {
1989        return Value::Null;
1990    }
1991    match field.type_name() {
1992        // §8.2 — typed casting; a failure nulls the cell but keeps the row.
1993        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        // §8.1 — automatic casting.
2011        None => cast_inline(part.text),
2012    }
2013}
2014
2015/// SYNX §8.3 automatic casting minus quote stripping (§8.1 + §7.1 step 3).
2016///
2017/// A part that survived §7.1 as *unquoted* keeps its quote characters as
2018/// ordinary content, so SYNX's "surrounded by quotes → literal inner text"
2019/// step must not run a second time; otherwise `"a"b"` would silently lose two
2020/// characters and break the §14.1 round-trip.
2021fn 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
2028/// SYNX §8.3 typed casting with explicit failure (§8.2).
2029fn cast_typed_checked(raw: &str, hint: &str) -> Option<Value> {
2030    match hint {
2031        "int" => raw.parse::<i64>().ok().map(Value::Int),
2032        // Non-finite floats have no JSON form, so they count as failures
2033        // rather than becoming a `null` with no diagnostic attached.
2034        "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        // SYNX §8.3: an unknown hint falls back to automatic casting, which
2042        // cannot fail.
2043        _ => Some(cast_inline(raw)),
2044    }
2045}
2046
2047// ─── §8.4 Validation (opt-in) ────────────────────────────────
2048
2049/// Mirror of the engine's constraint enforcement, reported instead of applied.
2050fn 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    // An unset optional field is not checked any further.
2059    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    // Numbers compare by value, strings by length — same rule as the engine.
2090    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                // An invalid regex is skipped silently, matching the engine.
2113                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
2125// ─── §14 Writer ──────────────────────────────────────────────
2126
2127/// Serialize a document, emitting a `!fields` line per run of records that
2128/// share a field list (§14, §4.2).
2129///
2130/// Fails with [`SynxlErrorKind::Unwritable`] when a value has no SYNXL
2131/// rendering (§14.3). That cannot happen for a document obtained by parsing —
2132/// §14.1 guarantees the round trip — so only a programmatically built value
2133/// can trigger it.
2134pub 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        // §4.2 — a document still declares its schema even with no rows.
2140        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
2163/// Serialize a single-schema record set (§14). See [`write_document`] for the
2164/// failure condition.
2165pub 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
2172/// Emit one `!fields` line and the records that use it.
2173fn write_group(
2174    out: &mut String,
2175    fields: &[FieldDecl],
2176    records: &[Value],
2177) -> Result<(), SynxlError> {
2178    // §14.3 — a value that cannot live inline is promoted, which is a property
2179    // of the whole column: block-ness is declared once, in the field list.
2180    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    // §5.3.4 — arity 0 is a hard error, so promotion must leave one column
2194    // inline. The candidate must be a declared-inline column none of whose
2195    // values contain `LF`, `CR`, or `;`; such a value survives as an unquoted
2196    // part byte-for-byte, losing at most significant edge whitespace, which
2197    // §14.1's scope note already exempts. One always exists for a document
2198    // that came from a parse — an inline part can hold none of those bytes.
2199    //
2200    // If none exists (only reachable for a programmatically built value), the
2201    // value has no rendering at all: §14.3 requires rejecting it loudly, and
2202    // emitting the zero-arity document anyway would be the forbidden half of
2203    // that rule — a document this crate's own parser refuses.
2204    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        // §7.2 — an all-null record is written as the single token `;`, which
2238        // is arity-independent and diagnostic-free. Joining empty parts would
2239        // produce an invisible line at arity 1 and a `MissingFields` at 3.
2240        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                // §9.2 — an absent block field needs no lines at all.
2272                _ => 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    // §5.3.2 — a promoted field drops its type; the block document carries the
2283    // shape instead.
2284    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
2327/// §14.3 — values that cannot be written as an inline part.
2328fn needs_block(value: &Value) -> bool {
2329    match value {
2330        // Structure has no inline form at all.
2331        Value::Object(_) | Value::Array(_) => true,
2332        Value::String(s) | Value::Secret(s) => {
2333            s.contains('\n')
2334                || s.contains('\r')
2335                // Quoting has no escapes (§7.4/§16.5), so a value that needs
2336                // quoting and contains both quote characters — the `;` + quote
2337                // case of §14.3 among others — has to be promoted.
2338                || (inline_needs_quote(s) && pick_quote(s).is_none())
2339        }
2340        _ => false,
2341    }
2342}
2343
2344/// Would this string be misread if written as a bare inline part? (§14.3)
2345fn 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        // A leading quote would make §7.1 step 2 read the part as quoted.
2353        || s.starts_with('"')
2354        || s.starts_with('\'')
2355        || cast_inline(s) != Value::String(s.to_string())
2356}
2357
2358/// A quote character that does not occur in `s`, or `None` if both do.
2359fn 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
2369/// §14.2 / §14.3 — one inline part.
2370fn write_inline(out: &mut String, value: &Value) {
2371    match value {
2372        // §14.2 — an unset field is an empty part.
2373        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                    // Unreachable for a promoted column; emit the raw text
2389                    // rather than losing the value outright.
2390                    None => out.push_str(s),
2391                }
2392            } else {
2393                out.push_str(s);
2394            }
2395        }
2396        // Promoted by `needs_block`; nothing sensible to write here.
2397        Value::Object(_) | Value::Array(_) => {}
2398    }
2399}
2400
2401/// Write a float so that the SYNX cast reads it back as the same float.
2402///
2403/// SYNX §8.3 only recognises `-?digits.digits`, so shortest-form output such
2404/// as `1e300` or `5` would come back as a string or an int. Non-finite values
2405/// have no JSON form either (`write_json` emits `null` for them), so they are
2406/// written as an empty part — whose projection is the same `null`.
2407fn 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    // Exponent form: spell the value out in full. f64 decimals terminate, so
2420    // the expansion is exact.
2421    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
2432// ─── §14 Block serialization (SYNX subset) ───────────────────
2433
2434/// Write `key: value` as SYNX at `indent` columns.
2435fn 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                // A bare key parses back as an empty object; the `:join` list
2455                // marker is the only SYNX surface that yields an empty array.
2456                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                // Not representable as a single SYNX token — fall back to an
2475                // indent-preserving block, whose body is never comment-stripped
2476                // nor cast.
2477                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
2486/// Write one `- item` list entry.
2487fn 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            // The `- ` line has to carry a key whose value fits on it; SYNX
2496            // attaches everything deeper to the *item*, not to that key.
2497            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                // An empty object is written as a bare key, which is exactly
2511                // how SYNX produced it in the first place.
2512                _ => {}
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
2534/// May this value share the `- ` line of a list item?
2535fn 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
2544/// `key |+` plus an indented body (§14.3).
2545fn 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
2556/// Render a scalar as a SYNX value token, or `None` when no token can carry it.
2557///
2558/// A SYNX value is comment-stripped at ` #` / ` //` *before* casting, so a
2559/// value containing either sequence cannot be rescued by quoting.
2560fn 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            // A non-finite float has no JSON form either — `write_json` emits
2567            // `null` for it, so `null` is the projection-preserving token.
2568            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
2588/// Would this string be misread as a bare SYNX value?
2589fn 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// ─── Small helpers ───────────────────────────────────────────
2599
2600#[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
2610/// Does `s` both start and end with the same ASCII quote, length ≥ 2?
2611fn 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
2623/// Truncate to at most `max` bytes at a valid UTF-8 boundary (§13).
2624fn 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
2635/// Shorten a fragment for an error message.
2636fn 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    // ── §4 document structure ───────────────────────────────
2658
2659    #[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        // Trailing garbage after the version is not a prologue.
2689        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        // §4.2 / §10 — records keep the schema they were parsed under.
2711        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    // ── §5 field list ───────────────────────────────────────
2728
2729    #[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        // §5.2 — a single marker run is forbidden too, not just a chain.
2742        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        // §5.2 — a version-1 parser stays readable for later producers.
2803        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    // ── §6 record lines ─────────────────────────────────────
2817
2818    #[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        // `//` is a comment, a single `/` is data, `#` is a comment, and a
2831        // record meant to start with a reserved prefix must quote it.
2832        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        // §4.1 — the `!filds` typo must not silently leave the old schema in
2839        // effect; SYNX directives are rejected for the same reason.
2840        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        // §4.1 — shards are concatenable when the version matches.
2854        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        // §5.3.4 — an all-block field list has no record line to write.
2864        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    // ── §7 inline fields ────────────────────────────────────
2871
2872    #[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        // §7.1 step 3: trailing garbage after the close quote ⇒ unquoted, and
2882        // §8.1 must not strip the quotes a second time.
2883        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        // §7.2 — the ambiguity CSV never resolved. Note the record line cannot
2903        // start with a space: indent > 0 would make it block content (§3.4).
2904        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        // §7.1 step 4 — Unicode whitespace is content, not padding. The NBSP
2919        // must not lead the *line*, though: §3.3 counts it as indentation
2920        // (SYNX §4 ltrims Unicode whitespace), which would make the line block
2921        // content rather than a record.
2922        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        // A leading NBSP is indentation, so this line is not a record at all.
2928        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        // §7.2 — `;` sets every inline field to null at any arity, silently.
2947        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        // Arity 3 would otherwise raise a spurious `MissingFields`.
2956        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        // Block fields are unaffected — the block is still parsed.
2961        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        // Only the exact `;` form is special: `;;` is an ordinary line of three
2966        // empty parts and keeps the §7.1 / §7.3 treatment.
2967        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        // §7.2 — the writer MUST emit this form; §14.1 then holds at arity 1,
2975        // where no other representation exists.
2976        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        // §7.5 — `#` and `//` are content in a dataset.
2999        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    // ── §8 casting ──────────────────────────────────────────
3007
3008    #[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    // ── §9 blocks ───────────────────────────────────────────
3045
3046    #[test]
3047    fn worked_example_from_the_spec() {
3048        // NB: a plain (non-continued) literal — a `\` line continuation would
3049        // strip the leading whitespace that makes these lines a block.
3050        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        // §9.3 — sorted key order keeps this deterministic despite the HashMap.
3100        assert_eq!(
3101            kinds(&d),
3102            vec![DiagnosticKind::BlockFieldNotDeclared, DiagnosticKind::UnknownBlockKey]
3103        );
3104        // §11.2 — these two report the *block* line carrying the key, not the
3105        // record line: `a` is on line 5, `bogus` on line 4.
3106        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        // The inline value stays authoritative.
3110        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        // `zzz` is the only unmatched top-level key, on line 8.
3127        assert_eq!(d.diagnostics[0].line, 8);
3128    }
3129
3130    #[test]
3131    fn type_hints_inside_a_cell_are_not_interpreted() {
3132        // §8.3 — casting is driven exclusively by the field list.
3133        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        // §9.4 — outside a multiline block a `!` line is discarded entirely.
3140        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        // §9.4 — inside `|+` the very same line is data.
3148        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        // §9.5 — no metadata surface, no marker resolution.
3159        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        // §11.2 — discarded, but never silently.
3167        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        // Line 3 belongs to record 0, which has not been read yet; line 6 has
3174        // no following record and is attached to the index one past the end.
3175        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    // ── §13 limits ──────────────────────────────────────────
3180
3181    #[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        // §13 — "truncated at a valid UTF-8 boundary".
3209        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        // §13 — the SYNX 16 MiB whole-input cap MUST NOT apply to SYNXL.
3246        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    // ── §15.1 streaming ─────────────────────────────────────
3260
3261    #[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        // The reader owns the text, so it can outlive the value it was built
3287        // from — no `unsafe`, no keeping the source alive by hand.
3288        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    // ── §15.1 streaming from io::BufRead ────────────────────
3326
3327    /// Read a document three ways — borrowed, owned, and off a `BufRead` — and
3328    /// require identical records and diagnostics from all three.
3329    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        // §13 — the per-record cap is the real memory bound for a stream, and
3399        // it must cut at the same offset the in-memory readers cut at.
3400        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        // A hostile document with no `LF` at all cannot be allowed to grow the
3447        // line buffer without bound; the cap applies to a single line too.
3448        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        // Format condition.
3463        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        // I/O condition — invalid UTF-8 is not a format verdict (§3.1).
3476        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    // ── §15.3 field-list source ─────────────────────────────
3497
3498    #[test]
3499    fn field_list_keeps_its_source_line() {
3500        // A splitting tool must re-emit the field list in effect verbatim.
3501        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        // Programmatically built lists have none.
3507        assert_eq!(FieldList::new(vec![FieldDecl::new("a")]).source(), "");
3508
3509        // The same text reaches a streaming consumer, which is where §15.3
3510        // shard emission actually happens.
3511        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    // ── §14 writer ──────────────────────────────────────────
3527
3528    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        // §14.3 — an LF forces promotion even though the field is inline.
3580        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        // A value with a `;` and both quote characters has no inline form.
3595        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        // §14.3 — the value needs quoting, holds both quote characters (so no
3609        // quoting is available) and a `;` (so it cannot fall back to a bare
3610        // part either). Promoting it is the only option, and promoting the one
3611        // and only column would leave arity 0 — which §5.3.4 rejects. The
3612        // writer must say so, not emit a document its own parser refuses.
3613        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        // Same value through a document.
3622        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        // §5.3.4 — promoting every column would produce an unparsable document,
3636        // so the last inline column stays inline even when it wants quoting.
3637        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    // ── §12 projections ─────────────────────────────────────
3675
3676    #[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    // ── splitter unit coverage ──────────────────────────────
3689
3690    #[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        // Close quote followed by garbage ⇒ unquoted, garbage included.
3705        let parts: Vec<Part> = PartSplitter::new("'x'y ; z").collect();
3706        assert_eq!(parts[0], Part { text: "'x'y", quoted: false });
3707        // A `;` inside quotes is not a delimiter.
3708        let parts: Vec<Part> = PartSplitter::new("\"a;b\"").collect();
3709        assert_eq!(parts, vec![Part { text: "a;b", quoted: true }]);
3710    }
3711}