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, HashSet};
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.
54///
55/// Matched to the SYNX parser's own nesting cap: a shallower writer would
56/// silently drop levels that a parse can produce, breaking §14.1 round-trip on
57/// documents this crate accepts. Anything deeper than a parse can yield is
58/// rejected up front (§14.3) instead of being trimmed away.
59const MAX_WRITE_DEPTH: usize = parser::MAX_PARSE_NESTING_DEPTH;
60
61/// Decimal places used when a float has to be expanded out of exponent form so
62/// that it survives a SYNX cast round-trip (see `write_float`). Large enough to
63/// spell out the smallest subnormal `f64` exactly.
64const FLOAT_EXPANSION_PRECISION: usize = 1_100;
65
66// ─── §5 Field list ───────────────────────────────────────────
67
68/// One declaration from a `!fields` line: `name(type)[constraints]`.
69#[derive(Debug, Clone, PartialEq)]
70pub struct FieldDecl {
71    /// Field name. Compared by exact Unicode scalar sequence (§5.1).
72    pub name: String,
73    /// The `(type)` production only. `type:<name>` lives in `constraints`;
74    /// use [`FieldDecl::type_name`] for the effective hint (§8.2).
75    pub type_hint: Option<String>,
76    /// Parsed by the SYNX constraint parser (§5.2). Not enforced unless the
77    /// caller opts into validating mode (§8.4).
78    pub constraints: Constraints,
79    /// The SYNXL-only `block` flag (§5.3).
80    pub block: bool,
81}
82
83impl FieldDecl {
84    /// A plain, untyped, unconstrained field.
85    pub fn new(name: impl Into<String>) -> Self {
86        Self {
87            name: name.into(),
88            type_hint: None,
89            constraints: Constraints::default(),
90            block: false,
91        }
92    }
93
94    /// The same, flagged `[block]` (§5.3).
95    pub fn new_block(name: impl Into<String>) -> Self {
96        Self { block: true, ..Self::new(name) }
97    }
98
99    /// Effective type hint: `(type)` wins over `type:<name>` (§8.2).
100    pub fn type_name(&self) -> Option<&str> {
101        self.type_hint
102            .as_deref()
103            .or(self.constraints.type_name.as_deref())
104    }
105}
106
107/// The ordered fields in effect for a run of records (§5).
108#[derive(Debug, Clone, PartialEq)]
109pub struct FieldList {
110    fields: Vec<FieldDecl>,
111    arity: usize,
112    source: String,
113    /// 1-based source line of the `!fields` line (0 when synthesised).
114    pub line: usize,
115}
116
117impl FieldList {
118    /// Build a field list, precomputing its arity.
119    ///
120    /// No validation is performed here; [`parse_lines`] rejects duplicate
121    /// names and the other §11.1 conditions while reading the `!fields` line.
122    pub fn new(fields: Vec<FieldDecl>) -> Self {
123        let arity = fields.iter().filter(|f| !f.block).count();
124        Self { fields, arity, source: String::new(), line: 0 }
125    }
126
127    /// The `!fields` line exactly as it appeared in the document, trimmed of
128    /// surrounding whitespace — empty for a programmatically built list.
129    ///
130    /// A tool that splits or concatenates documents (§15.3) has to re-emit the
131    /// field list in effect at the split point; replaying the original bytes
132    /// keeps the shards byte-identical to the source, which re-deriving the
133    /// declaration from [`FieldList::fields`] would not (§14 normalises the
134    /// separator to `; ` and drops the author's spacing).
135    pub fn source(&self) -> &str {
136        &self.source
137    }
138
139    /// Declarations in source order, block fields included.
140    pub fn fields(&self) -> &[FieldDecl] {
141        &self.fields
142    }
143
144    /// Number of fields **without** the `block` flag — the expected count of
145    /// inline parts per record (§5.3).
146    pub fn arity(&self) -> usize {
147        self.arity
148    }
149
150    /// Look a declaration up by exact name.
151    pub fn get(&self, name: &str) -> Option<&FieldDecl> {
152        self.fields.iter().find(|f| f.name == name)
153    }
154}
155
156// ─── §11 Error model ─────────────────────────────────────────
157
158/// Hard-error conditions (§11.1). Every one of these aborts the parse; no
159/// partial result is produced.
160#[derive(Debug, Clone, Copy, PartialEq, Eq)]
161pub enum SynxlErrorKind {
162    /// Missing or malformed `!synxl <version>` prologue (§4.1).
163    MissingPrologue,
164    /// Prologue declares a version this implementation does not support, or a
165    /// repeated prologue declares a different version (§1.3, §4.1). For format
166    /// version 1 the two conditions coincide: "different" implies "not 1".
167    UnsupportedVersion,
168    /// A line at indent 0 beginning with `!` that is neither a prologue nor a
169    /// field list — including SYNX directives such as `!active` (§4.1).
170    UnknownDirective,
171    /// Record line encountered while no field list is in effect (§4.2).
172    NoFieldList,
173    /// Empty or unparsable `!fields` line (§5).
174    MalformedFieldList,
175    /// Duplicate field name within one field list (§5.1).
176    DuplicateField,
177    /// A marker run — chain (`:a:b`) or single (`:custom`) — in a field
178    /// declaration (§5.2).
179    MarkerChain,
180    /// `random` / `random:int` / `random:float` / `random:bool` hint (§8.3).
181    NonDeterministicHint,
182    /// `block` combined with `(type)` or `type:` (§5.3).
183    BlockWithType,
184    /// A field list whose every field carries `block`, i.e. arity 0 (§5.3.4).
185    ZeroArity,
186    /// **Writer only.** A value has no SYNXL rendering: it must be promoted to
187    /// a block (§14.3) but promoting it would leave the field list at arity 0
188    /// (§5.3.4). §14.3 requires rejecting such a value loudly — emitting a
189    /// document that reads back differently, or one that no reader accepts, is
190    /// not permitted. Cannot arise for a value obtained by parsing (§14.1).
191    Unwritable,
192    /// A §13 limit other than `MAX_SYNXL_RECORD_BYTES` was exceeded.
193    LimitExceeded,
194}
195
196impl SynxlErrorKind {
197    pub fn as_str(self) -> &'static str {
198        match self {
199            SynxlErrorKind::MissingPrologue => "MissingPrologue",
200            SynxlErrorKind::UnsupportedVersion => "UnsupportedVersion",
201            SynxlErrorKind::UnknownDirective => "UnknownDirective",
202            SynxlErrorKind::NoFieldList => "NoFieldList",
203            SynxlErrorKind::MalformedFieldList => "MalformedFieldList",
204            SynxlErrorKind::DuplicateField => "DuplicateField",
205            SynxlErrorKind::MarkerChain => "MarkerChain",
206            SynxlErrorKind::NonDeterministicHint => "NonDeterministicHint",
207            SynxlErrorKind::BlockWithType => "BlockWithType",
208            SynxlErrorKind::ZeroArity => "ZeroArity",
209            SynxlErrorKind::Unwritable => "Unwritable",
210            SynxlErrorKind::LimitExceeded => "LimitExceeded",
211        }
212    }
213}
214
215impl fmt::Display for SynxlErrorKind {
216    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
217        f.write_str(self.as_str())
218    }
219}
220
221/// A hard error (§11.1) with the 1-based source line that produced it.
222#[derive(Debug, Clone, PartialEq, Eq)]
223pub struct SynxlError {
224    pub kind: SynxlErrorKind,
225    pub line: usize,
226    pub message: String,
227}
228
229impl SynxlError {
230    /// Build an error.
231    ///
232    /// Public because wrappers around this crate — language bindings in
233    /// particular — need to raise the same error type for conditions they
234    /// enforce themselves (a writer rejecting `block` combined with a type,
235    /// say) instead of assembling the struct literally.
236    pub fn new(kind: SynxlErrorKind, line: usize, message: impl Into<String>) -> Self {
237        Self { kind, line, message: message.into() }
238    }
239}
240
241impl fmt::Display for SynxlError {
242    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
243        write!(f, "SYNXL {} at line {}: {}", self.kind, self.line, self.message)
244    }
245}
246
247impl std::error::Error for SynxlError {}
248
249/// Recoverable parse observations (§11.2). Reported, never dropped silently.
250#[derive(Debug, Clone, Copy, PartialEq, Eq)]
251pub enum DiagnosticKind {
252    /// Fewer inline parts than the arity; trailing fields set to `null` (§7.3).
253    MissingFields,
254    /// More inline parts than the arity; the surplus was discarded (§7.3).
255    ExtraFields,
256    /// Typed casting failed; the field was set to `null` (§8.2).
257    CastFailed,
258    /// A block key that matches no declared field (§9.3).
259    UnknownBlockKey,
260    /// A block key matching a field that is **not** declared `[block]` (§9.3).
261    BlockFieldNotDeclared,
262    /// A line at indent > 0 where no record is open — for example right after
263    /// a field list. Discarded, and attached to the *following* record (§11.2).
264    OrphanBlockLine,
265    /// A declared constraint was violated — validating mode only (§8.4).
266    ConstraintViolation,
267    /// The record exceeded `MAX_SYNXL_RECORD_BYTES` and was truncated (§13).
268    RecordTruncated,
269}
270
271impl DiagnosticKind {
272    pub fn as_str(self) -> &'static str {
273        match self {
274            DiagnosticKind::MissingFields => "MissingFields",
275            DiagnosticKind::ExtraFields => "ExtraFields",
276            DiagnosticKind::CastFailed => "CastFailed",
277            DiagnosticKind::UnknownBlockKey => "UnknownBlockKey",
278            DiagnosticKind::BlockFieldNotDeclared => "BlockFieldNotDeclared",
279            DiagnosticKind::OrphanBlockLine => "OrphanBlockLine",
280            DiagnosticKind::ConstraintViolation => "ConstraintViolation",
281            DiagnosticKind::RecordTruncated => "RecordTruncated",
282        }
283    }
284}
285
286impl fmt::Display for DiagnosticKind {
287    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
288        f.write_str(self.as_str())
289    }
290}
291
292/// One diagnostic: record index (0-based), source line (1-based), kind, and a
293/// human-readable message (§11.2).
294#[derive(Debug, Clone, PartialEq, Eq)]
295pub struct Diagnostic {
296    pub record_index: usize,
297    pub line: usize,
298    pub kind: DiagnosticKind,
299    pub message: String,
300}
301
302impl fmt::Display for Diagnostic {
303    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
304        write!(
305            f,
306            "{} (record {}, line {}): {}",
307            self.kind, self.record_index, self.line, self.message
308        )
309    }
310}
311
312// ─── Options ─────────────────────────────────────────────────
313
314/// Parse options. Defaults follow §8.4: validation is opt-in.
315#[derive(Debug, Clone, Default)]
316pub struct SynxlOptions {
317    /// Enforce declared constraints and report violations as
318    /// [`DiagnosticKind::ConstraintViolation`] (§8.4).
319    pub validate: bool,
320}
321
322// ─── Parse results ───────────────────────────────────────────
323
324/// One record produced by [`SynxlReader`].
325#[derive(Debug, Clone, PartialEq)]
326pub struct SynxlRecord {
327    /// 0-based position in the document.
328    pub index: usize,
329    /// 1-based source line of the record line.
330    pub line: usize,
331    /// Index into [`SynxlReader::field_lists`] of the list in effect.
332    pub field_list: usize,
333    /// Always a [`Value::Object`].
334    pub value: Value,
335    /// Diagnostics produced by this record alone (§11.2).
336    pub diagnostics: Vec<Diagnostic>,
337}
338
339/// A fully materialised SYNXL document.
340#[derive(Debug, Clone, PartialEq)]
341pub struct SynxlDocument {
342    /// Format version from the prologue (§4.1).
343    pub version: u32,
344    /// Records in document order; each is a [`Value::Object`].
345    pub records: Vec<Value>,
346    /// Every field list in declaration order (§4.2).
347    pub field_lists: Vec<FieldList>,
348    /// `records[i]` was parsed under `field_lists[record_field_lists[i]]`.
349    pub record_field_lists: Vec<usize>,
350    /// 1-based source line of each record's record line.
351    pub record_lines: Vec<usize>,
352    /// All diagnostics, in record order (§11.2).
353    pub diagnostics: Vec<Diagnostic>,
354}
355
356impl SynxlDocument {
357    pub fn len(&self) -> usize {
358        self.records.len()
359    }
360
361    pub fn is_empty(&self) -> bool {
362        self.records.is_empty()
363    }
364
365    /// The field list that was in effect for record `index`.
366    pub fn field_list_for(&self, index: usize) -> Option<&FieldList> {
367        self.record_field_lists
368            .get(index)
369            .and_then(|i| self.field_lists.get(*i))
370    }
371
372    /// Canonical JSON **array** projection (§12.1).
373    pub fn to_json(&self) -> String {
374        records_to_json_array(&self.records)
375    }
376
377    /// Canonical **NDJSON** projection (§12.2).
378    pub fn to_ndjson(&self) -> String {
379        records_to_ndjson(&self.records)
380    }
381
382    /// Canonical SYNXL serialization (§14).
383    ///
384    /// Infallible in practice for a parsed document (§14.1); see
385    /// [`write_document`] for the one condition that rejects.
386    pub fn to_synxl(&self) -> Result<String, SynxlError> {
387        write_document(self)
388    }
389}
390
391/// Canonical JSON array projection of a record slice (§12.1).
392pub fn records_to_json_array(records: &[Value]) -> String {
393    let mut out = String::with_capacity(records.len() * 64 + 2);
394    out.push('[');
395    for (i, rec) in records.iter().enumerate() {
396        if i > 0 {
397            out.push(',');
398        }
399        crate::write_json(&mut out, rec);
400    }
401    out.push(']');
402    out
403}
404
405/// Canonical NDJSON projection of a record slice (§12.2): one canonical object
406/// per line, `LF`-separated, no enclosing array.
407pub fn records_to_ndjson(records: &[Value]) -> String {
408    let mut out = String::with_capacity(records.len() * 64);
409    for rec in records {
410        crate::write_json(&mut out, rec);
411        out.push('\n');
412    }
413    out
414}
415
416// ─── §15.1 Streaming reader ──────────────────────────────────
417
418/// A single physical line, kept as offsets into the document.
419///
420/// Offsets rather than slices are what let one state machine serve both the
421/// borrowing and the owning reader: the state carries no lifetime, so the
422/// owning reader can keep its `String` beside it without `unsafe`.
423#[derive(Debug, Clone, Copy)]
424struct Line {
425    /// Byte offset of the line's first byte.
426    start: usize,
427    /// Byte offset one past its last byte, `CR` / `LF` excluded.
428    end: usize,
429    /// 1-based line number.
430    no: usize,
431}
432
433impl Line {
434    /// Line content with `CR`/`LF` removed, indentation intact.
435    #[inline]
436    fn raw<'t>(&self, text: &'t str) -> &'t str {
437        &text[self.start..self.end]
438    }
439
440    #[inline]
441    fn indent(&self, text: &str) -> usize {
442        let raw = self.raw(text);
443        raw.len() - raw.trim_start().len()
444    }
445}
446
447/// What a non-empty line at indent 0 turned out to be (§4.3, §6).
448enum LineKind {
449    /// A comment, a directive, or a field list — already applied, nothing to
450    /// hand back to the caller.
451    Skip,
452    /// A record line; the caller must frame its block and call `build`.
453    Record,
454}
455
456/// One record's text, already framed by whichever line source produced it.
457///
458/// Framing is the only part of reading that differs between an in-memory
459/// document (slices) and an `io::BufRead` (a bounded buffer); everything the
460/// format defines happens in [`ReaderCore::build`], which takes this.
461struct RecordFrame<'t> {
462    /// The record line, `CR`/`LF` excluded.
463    line: &'t str,
464    /// 1-based number of the record line.
465    line_no: usize,
466    /// The block's raw text, original indentation intact, `LF`-joined (§9.3).
467    block: &'t str,
468    /// 1-based number of the block's first line (0 when there is no block).
469    block_first_no: usize,
470    /// Total bytes before truncation, when §13's per-record cap was hit.
471    truncated_total: Option<usize>,
472}
473
474/// The reader state machine, independent of how the document is owned.
475///
476/// Both [`SynxlReader`] (borrowing) and [`SynxlReaderOwned`] (owning) drive
477/// this one type, handing it their text on each call. The parse path is shared
478/// rather than forked, and neither wrapper — nor any binding built on them —
479/// needs `unsafe` to keep a document alive alongside its reader.
480#[derive(Debug)]
481struct ReaderCore {
482    /// Byte offset of the next line to read.
483    pos: usize,
484    /// 1-based number of the next line to read.
485    line_no: usize,
486    /// Look-ahead line pushed back by the block scanner.
487    pending: Option<Line>,
488    /// §11.2 — `OrphanBlockLine` diagnostics found while looking for the next
489    /// record, which they are attached to.
490    pending_diagnostics: Vec<Diagnostic>,
491    in_block_comment: bool,
492    field_lists: Vec<FieldList>,
493    current: Option<usize>,
494    record_index: usize,
495    version: u32,
496    done: bool,
497    opts: SynxlOptions,
498}
499
500impl ReaderCore {
501    /// Fresh state, before any input has been seen.
502    fn empty(opts: SynxlOptions) -> Self {
503        Self {
504            pos: 0,
505            line_no: 1,
506            pending: None,
507            pending_diagnostics: Vec::new(),
508            in_block_comment: false,
509            field_lists: Vec::new(),
510            current: None,
511            record_index: 0,
512            version: 0,
513            done: false,
514            opts,
515        }
516    }
517
518    /// Build the state and validate the prologue eagerly (§4.1).
519    fn start(text: &str, opts: SynxlOptions) -> Result<Self, SynxlError> {
520        let mut core = Self {
521            // §3.1 — a leading U+FEFF is ignored. Skipping it by offset instead
522            // of re-slicing keeps every later offset relative to the caller's
523            // own text, which is what the owning reader indexes into.
524            pos: if text.starts_with('\u{feff}') {
525                '\u{feff}'.len_utf8()
526            } else {
527                0
528            },
529            line_no: 1,
530            pending: None,
531            pending_diagnostics: Vec::new(),
532            in_block_comment: false,
533            field_lists: Vec::new(),
534            current: None,
535            record_index: 0,
536            version: 0,
537            done: false,
538            opts,
539        };
540        core.read_prologue(text)?;
541        Ok(core)
542    }
543
544    /// Next physical line, honouring the block scanner's push-back.
545    fn read_line(&mut self, text: &str) -> Option<Line> {
546        if let Some(line) = self.pending.take() {
547            return Some(line);
548        }
549        let bytes = text.as_bytes();
550        if self.pos >= bytes.len() {
551            return None;
552        }
553        let start = self.pos;
554        let (mut end, next) = match memchr(b'\n', &bytes[start..]) {
555            Some(rel) => (start + rel, start + rel + 1),
556            None => (bytes.len(), bytes.len()),
557        };
558        // §3.2 — a CR immediately before LF is not part of the line.
559        if end > start && bytes[end - 1] == b'\r' {
560            end -= 1;
561        }
562        self.pos = next;
563        let no = self.line_no;
564        self.line_no += 1;
565        Some(Line { start, end, no })
566    }
567
568    /// One iteration step: the body behind both readers' `Iterator::next`.
569    fn next_item(&mut self, text: &str) -> Option<Result<SynxlRecord, SynxlError>> {
570        if self.done {
571            return None;
572        }
573        match self.next_record(text) {
574            Ok(Some(rec)) => Some(Ok(rec)),
575            Ok(None) => {
576                self.done = true;
577                None
578            }
579            Err(err) => {
580                // §11.1 — a hard error ends the document.
581                self.done = true;
582                Some(Err(err))
583            }
584        }
585    }
586
587    /// §4.1 — does the prologue scan skip this line? Toggles `###` state.
588    ///
589    /// Shared by every line source: the prologue must be found the same way
590    /// whether the document is a `&str` or an `io::BufRead`.
591    fn prologue_skip(&mut self, trimmed: &str) -> bool {
592        if trimmed.is_empty() {
593            return true;
594        }
595        // `###` is matched before the `#` line-comment rule (§4.3).
596        if trimmed == "###" {
597            self.in_block_comment = !self.in_block_comment;
598            return true;
599        }
600        if self.in_block_comment {
601            return true;
602        }
603        trimmed.starts_with('#') || trimmed.starts_with("//")
604    }
605
606    /// The error raised when input ends before a prologue is found (§4.1).
607    fn missing_prologue(last_line: usize) -> SynxlError {
608        SynxlError::new(
609            SynxlErrorKind::MissingPrologue,
610            last_line.max(1),
611            "document has no `!synxl <version>` prologue",
612        )
613    }
614
615    /// §4.1 — the first non-empty, non-comment line MUST be the prologue.
616    fn read_prologue(&mut self, text: &str) -> Result<(), SynxlError> {
617        while let Some(line) = self.read_line(text) {
618            let trimmed = line.raw(text).trim();
619            if self.prologue_skip(trimmed) {
620                continue;
621            }
622            self.version = parse_prologue(trimmed, line.no)?;
623            return Ok(());
624        }
625        Err(Self::missing_prologue(self.line_no.saturating_sub(1)))
626    }
627
628    /// §11.2 — record an indented line that has no open record.
629    fn note_orphan(&mut self, trimmed: &str, no: usize) {
630        self.pending_diagnostics.push(Diagnostic {
631            record_index: self.record_index,
632            line: no,
633            kind: DiagnosticKind::OrphanBlockLine,
634            message: format!("indented line `{}` has no open record; discarded", elide(trimmed)),
635        });
636    }
637
638    /// Decide what a non-empty line at indent 0 is (§4.3, §6).
639    ///
640    /// This is the whole structural vocabulary of the format in one place, so
641    /// every line source classifies identically; only the byte plumbing around
642    /// it differs.
643    fn classify(&mut self, trimmed: &str, no: usize) -> Result<LineKind, SynxlError> {
644        if trimmed == "###" {
645            self.in_block_comment = !self.in_block_comment;
646            return Ok(LineKind::Skip);
647        }
648        if self.in_block_comment {
649            return Ok(LineKind::Skip);
650        }
651
652        // §4.3 — the `!` forms are matched before the comment rules.
653        if trimmed.starts_with('!') {
654            if let Some(rest) = trimmed.strip_prefix("!fields") {
655                if rest.is_empty() || starts_with_wsp(rest) {
656                    let list = parse_field_list(rest.trim(), trimmed, no)?;
657                    self.push_field_list(list)?;
658                    return Ok(LineKind::Skip);
659                }
660            }
661            if let Some(rest) = trimmed.strip_prefix("!synxl") {
662                if rest.is_empty() || starts_with_wsp(rest) {
663                    // §4.1 — a repeated prologue is accepted and ignored when it
664                    // declares the same version (shards are concatenable), and
665                    // rejected when it does not.
666                    parse_prologue(trimmed, no)?;
667                    return Ok(LineKind::Skip);
668                }
669            }
670            // §4.1 — anything else beginning with `!` at indent 0 is a hard
671            // error. Ignoring it would let a typo (`!filds …`) leave the
672            // previous field list in effect and mis-populate every subsequent
673            // record without any signal.
674            return Err(SynxlError::new(
675                SynxlErrorKind::UnknownDirective,
676                no,
677                format!(
678                    "`{}` is neither a prologue nor a field list; a record starting with `!` must quote it",
679                    elide(trimmed)
680                ),
681            ));
682        }
683        if trimmed.starts_with('#') || trimmed.starts_with("//") {
684            return Ok(LineKind::Skip);
685        }
686
687        // §6 — anything else at indent 0 is a record line. The SYNX §7
688        // first-character filter deliberately does NOT apply here.
689        Ok(LineKind::Record)
690    }
691
692    /// The field list in effect, or the §4.2 hard error.
693    fn require_field_list(&self, no: usize) -> Result<usize, SynxlError> {
694        self.current.ok_or_else(|| {
695            SynxlError::new(
696                SynxlErrorKind::NoFieldList,
697                no,
698                "record line with no `!fields` in effect",
699            )
700        })
701    }
702
703    /// Install a freshly parsed field list, enforcing §13's list cap.
704    fn push_field_list(&mut self, list: FieldList) -> Result<(), SynxlError> {
705        if self.field_lists.len() >= MAX_SYNXL_FIELD_LISTS {
706            return Err(SynxlError::new(
707                SynxlErrorKind::LimitExceeded,
708                list.line,
709                format!("more than {} field lists in one document", MAX_SYNXL_FIELD_LISTS),
710            ));
711        }
712        self.field_lists.push(list);
713        self.current = Some(self.field_lists.len() - 1);
714        Ok(())
715    }
716
717    /// Advance to the next record, consuming structural lines on the way.
718    fn next_record(&mut self, text: &str) -> Result<Option<SynxlRecord>, SynxlError> {
719        loop {
720            let line = match self.read_line(text) {
721                Some(l) => l,
722                None => return Ok(None),
723            };
724            let trimmed = line.raw(text).trim();
725            if trimmed.is_empty() {
726                continue;
727            }
728            // §4.3 — a `###` block comment ignores every line until the closing
729            // marker, whatever its indent. Checked before the orphan rule
730            // below: a line that the format says to ignore must not also be
731            // reported as an orphan.
732            if self.in_block_comment {
733                if trimmed == "###" {
734                    self.in_block_comment = false;
735                }
736                continue;
737            }
738            // §3.4 — indent > 0 belongs to the current record's block. Reaching
739            // this loop means no record is open, so the line is orphaned: it is
740            // discarded and reported against the *following* record (§11.2).
741            if line.indent(text) > 0 {
742                self.note_orphan(trimmed, line.no);
743                continue;
744            }
745            if let LineKind::Skip = self.classify(trimmed, line.no)? {
746                continue;
747            }
748
749            let fl_idx = self.require_field_list(line.no)?;
750
751            // §9.1 — the block is the maximal run of following lines up to the
752            // next non-empty line at indent 0. Empty lines do not terminate it
753            // (§3.5).
754            let mut block_start: Option<usize> = None;
755            let mut block_end: Option<usize> = None;
756            let mut block_first_no = 0usize;
757            loop {
758                let next = match self.read_line(text) {
759                    Some(l) => l,
760                    None => break,
761                };
762                if next.raw(text).trim().is_empty() {
763                    continue;
764                }
765                if next.indent(text) == 0 {
766                    self.pending = Some(next);
767                    break;
768                }
769                if block_start.is_none() {
770                    block_start = Some(next.start);
771                    block_first_no = next.no;
772                }
773                block_end = Some(next.end);
774            }
775            // Block lines are contiguous in the source, so the whole block is
776            // one slice — no per-line copying, and `|+` base-indent locking
777            // still sees the original indentation (§9.3).
778            let block_raw = match (block_start, block_end) {
779                (Some(s), Some(e)) => &text[s..e],
780                _ => "",
781            };
782
783            // §13 — truncate rather than fail. The record line is kept first:
784            // it carries the inline fields, the more valuable half.
785            let mut record_line = line.raw(text);
786            let mut block_text = block_raw;
787            let total = record_line.len().saturating_add(block_text.len());
788            let mut truncated_total = None;
789            if total > MAX_SYNXL_RECORD_BYTES {
790                truncated_total = Some(total);
791                if record_line.len() >= MAX_SYNXL_RECORD_BYTES {
792                    record_line = truncate_utf8(record_line, MAX_SYNXL_RECORD_BYTES);
793                    block_text = "";
794                } else {
795                    block_text =
796                        truncate_utf8(block_text, MAX_SYNXL_RECORD_BYTES - record_line.len());
797                }
798            }
799
800            return self
801                .build(
802                    fl_idx,
803                    RecordFrame {
804                        line: record_line,
805                        line_no: line.no,
806                        block: block_text,
807                        block_first_no,
808                        truncated_total,
809                    },
810                )
811                .map(Some);
812        }
813    }
814
815    /// Turn one framed record into a [`SynxlRecord`] (§7, §8, §9, §11.2).
816    ///
817    /// Framing — finding the record line and its block — belongs to the line
818    /// source; everything the format defines happens here, once.
819    fn build(&mut self, fl_idx: usize, frame: RecordFrame<'_>) -> Result<SynxlRecord, SynxlError> {
820        let RecordFrame {
821            line: record_line,
822            line_no,
823            block: block_text,
824            block_first_no,
825            truncated_total,
826        } = frame;
827
828        let index = self.record_index;
829        self.record_index += 1;
830        // Orphan lines physically precede the record they are attached to, so
831        // they lead its diagnostic list (§11.2 orders the other kinds).
832        let mut diagnostics: Vec<Diagnostic> = std::mem::take(&mut self.pending_diagnostics);
833        if let Some(total) = truncated_total {
834            diagnostics.push(Diagnostic {
835                record_index: index,
836                line: line_no,
837                kind: DiagnosticKind::RecordTruncated,
838                message: format!(
839                    "record is {} bytes, truncated to the {} byte limit",
840                    total, MAX_SYNXL_RECORD_BYTES
841                ),
842            });
843        }
844
845        // §9.3 — delegate the block to the SYNX parser *before* borrowing the
846        // field list, so the borrow checker keeps `self` free for the read.
847        let block_root = if block_text.trim().is_empty() {
848            None
849        } else {
850            // §9.4 — directives are disabled inside the embedded parse, and
851            // §9.5 — no `!active` mode means no metadata capture.
852            let parsed = parser::parse_with(
853                block_text,
854                ParserOptions { directives: false, nondeterministic_hints: false },
855            );
856            // SYNX's own caps (multiline body bytes, indexed lines) apply
857            // within a block, scoped to that block (§13). When one of them
858            // drops content, saying nothing would make an accepted record
859            // indistinguishable from a complete one — the exact failure §16.4
860            // tells consumers to guard against.
861            if parsed.truncated {
862                diagnostics.push(Diagnostic {
863                    record_index: index,
864                    line: line_no,
865                    kind: DiagnosticKind::RecordTruncated,
866                    message: "block content exceeded a SYNX parser limit (§13) and was truncated"
867                        .to_string(),
868                });
869            }
870            match parsed.root {
871                Value::Object(map) => Some(map),
872                _ => None,
873            }
874        };
875
876        let fl = &self.field_lists[fl_idx];
877        let mut obj: HashMap<String, Value> = HashMap::with_capacity(fl.fields.len());
878
879        // `trim_wsp`, not `trim`: §3.3 draws the line between structure and
880        // content, and trimming *within* a record line takes only spaces and
881        // horizontal tabs. A trailing U+00A0 is field content (§7.1 step 4),
882        // so `;\u{a0}` is a two-part record, not the all-null form.
883        if trim_wsp(record_line) == ";" {
884            // §7.2 — the all-null record. Matched *before* the §7.1 split,
885            // whose result would otherwise depend on the arity: two null parts
886            // at arity 2, but a spurious `MissingFields` at arity 3. At arity 1
887            // this form is the only representation an all-null record has, an
888            // empty line being invisible (§3.5).
889            for field in fl.fields.iter().filter(|f| !f.block) {
890                obj.insert(field.name.clone(), Value::Null);
891            }
892        } else {
893            // §7 — inline fields, positionally matched against non-block fields.
894            let mut inline_fields = fl.fields.iter().filter(|f| !f.block);
895            let mut parts = PartSplitter::new(record_line);
896            let mut part_count = 0usize;
897            loop {
898                let part = match parts.next() {
899                    Some(p) => p,
900                    None => break,
901                };
902                part_count += 1;
903                match inline_fields.next() {
904                    Some(field) => {
905                        let value = cast_part(field, part, index, line_no, &mut diagnostics);
906                        obj.insert(field.name.clone(), value);
907                    }
908                    // §7.3 — surplus parts are discarded but still counted.
909                    None => {}
910                }
911            }
912            let mut missing = 0usize;
913            for field in inline_fields {
914                obj.insert(field.name.clone(), Value::Null);
915                missing += 1;
916            }
917            if missing > 0 {
918                diagnostics.push(Diagnostic {
919                    record_index: index,
920                    line: line_no,
921                    kind: DiagnosticKind::MissingFields,
922                    message: format!(
923                        "record has {} inline part(s), field list declares {}; {} trailing field(s) set to null",
924                        part_count,
925                        fl.arity(),
926                        missing
927                    ),
928                });
929            } else if part_count > fl.arity() {
930                diagnostics.push(Diagnostic {
931                    record_index: index,
932                    line: line_no,
933                    kind: DiagnosticKind::ExtraFields,
934                    message: format!(
935                        "record has {} inline part(s), field list declares {}; {} discarded",
936                        part_count,
937                        fl.arity(),
938                        part_count - fl.arity()
939                    ),
940                });
941            }
942        }
943
944        // §9.2 — a block field with no block value is null.
945        for field in fl.fields.iter().filter(|f| f.block) {
946            obj.insert(field.name.clone(), Value::Null);
947        }
948
949        // §11.2 — block diagnostics report the line *inside the block* that
950        // carries the offending key, so the block's key/line map is built the
951        // first time one is needed (never for a clean record).
952        let mut key_lines: Option<HashMap<&str, usize>> = None;
953
954        // §9.3 — match block keys by exact name, visiting them in lexicographic
955        // order so the diagnostic sequence is reproducible despite the HashMap.
956        if let Some(map) = block_root {
957            let mut entries: Vec<(String, Value)> = map.into_iter().collect();
958            entries.sort_unstable_by(|a, b| a.0.cmp(&b.0));
959            for (key, value) in entries {
960                match fl.get(&key) {
961                    Some(field) if field.block => {
962                        obj.insert(field.name.clone(), value);
963                    }
964                    Some(_) => {
965                        let at = key_lines
966                            .get_or_insert_with(|| block_key_lines(block_text, block_first_no))
967                            .get(key.as_str())
968                            .copied()
969                            .unwrap_or(line_no);
970                        diagnostics.push(Diagnostic {
971                            record_index: index,
972                            line: at,
973                            kind: DiagnosticKind::BlockFieldNotDeclared,
974                            message: format!(
975                                "block key `{}` matches a field that is not declared [block]; inline value kept",
976                                key
977                            ),
978                        });
979                    }
980                    None => {
981                        let at = key_lines
982                            .get_or_insert_with(|| block_key_lines(block_text, block_first_no))
983                            .get(key.as_str())
984                            .copied()
985                            .unwrap_or(line_no);
986                        diagnostics.push(Diagnostic {
987                            record_index: index,
988                            line: at,
989                            kind: DiagnosticKind::UnknownBlockKey,
990                            message: format!("block key `{}` matches no declared field", key),
991                        });
992                    }
993                }
994            }
995        }
996
997        // §8.4 — validation is opt-in.
998        if self.opts.validate {
999            for field in fl.fields.iter() {
1000                if let Some(value) = obj.get(&field.name) {
1001                    if let Some(msg) = check_constraints(&field.name, value, &field.constraints) {
1002                        // §11.2 — the record line for an inline field, the
1003                        // offending block line for a block field.
1004                        let at = if field.block {
1005                            key_lines
1006                                .get_or_insert_with(|| block_key_lines(block_text, block_first_no))
1007                                .get(field.name.as_str())
1008                                .copied()
1009                                .unwrap_or(line_no)
1010                        } else {
1011                            line_no
1012                        };
1013                        diagnostics.push(Diagnostic {
1014                            record_index: index,
1015                            line: at,
1016                            kind: DiagnosticKind::ConstraintViolation,
1017                            message: msg,
1018                        });
1019                    }
1020                }
1021            }
1022        }
1023
1024        Ok(SynxlRecord {
1025            index,
1026            line: line_no,
1027            field_list: fl_idx,
1028            value: Value::Object(obj),
1029            diagnostics,
1030        })
1031    }
1032}
1033
1034/// Streaming record reader over borrowed text (§15.1).
1035///
1036/// Record boundaries are decidable from a single byte (§3.4), so records are
1037/// produced incrementally without materialising the document. The prologue is
1038/// validated eagerly by [`SynxlReader::new`]; every later hard error surfaces
1039/// as an `Err` item, after which iteration stops.
1040///
1041/// Use [`SynxlReaderOwned`] when the reader has to outlive the expression that
1042/// produced the text — an FFI wrapper, for instance.
1043///
1044/// ```rust
1045/// use synx_core::synxl::SynxlReader;
1046///
1047/// let src = "!synxl 1\n!fields a ; b\n1 ; 2\n3 ; 4\n";
1048/// let mut n = 0;
1049/// for rec in SynxlReader::new(src).unwrap() {
1050///     assert!(rec.unwrap().diagnostics.is_empty());
1051///     n += 1;
1052/// }
1053/// assert_eq!(n, 2);
1054/// ```
1055#[derive(Debug)]
1056pub struct SynxlReader<'a> {
1057    text: &'a str,
1058    core: ReaderCore,
1059}
1060
1061impl<'a> SynxlReader<'a> {
1062    /// Open a reader, validating the prologue (§4.1).
1063    pub fn new(text: &'a str) -> Result<Self, SynxlError> {
1064        Self::with_options(text, SynxlOptions::default())
1065    }
1066
1067    /// Open a reader with explicit options (§8.4).
1068    pub fn with_options(text: &'a str, opts: SynxlOptions) -> Result<Self, SynxlError> {
1069        Ok(Self { text, core: ReaderCore::start(text, opts)? })
1070    }
1071
1072    /// Declared format version (§4.1).
1073    pub fn version(&self) -> u32 {
1074        self.core.version
1075    }
1076
1077    /// Field lists seen so far, in declaration order.
1078    pub fn field_lists(&self) -> &[FieldList] {
1079        &self.core.field_lists
1080    }
1081
1082    /// The field list currently in effect, if any (§4.2).
1083    pub fn field_list(&self) -> Option<&FieldList> {
1084        self.core.current.map(|i| &self.core.field_lists[i])
1085    }
1086
1087    /// Consume the reader, keeping the field lists it collected.
1088    pub fn into_field_lists(self) -> Vec<FieldList> {
1089        self.core.field_lists
1090    }
1091
1092    /// Diagnostics that were found after the last record and therefore had no
1093    /// record to attach to — `OrphanBlockLine` at end of input (§11.2). Empty
1094    /// until iteration finishes.
1095    pub fn trailing_diagnostics(&self) -> &[Diagnostic] {
1096        &self.core.pending_diagnostics
1097    }
1098}
1099
1100impl Iterator for SynxlReader<'_> {
1101    type Item = Result<SynxlRecord, SynxlError>;
1102
1103    fn next(&mut self) -> Option<Self::Item> {
1104        self.core.next_item(self.text)
1105    }
1106}
1107
1108/// Streaming record reader that **owns** its document (§15.1).
1109///
1110/// Identical to [`SynxlReader`] in behaviour and code path — the two share one
1111/// state machine — but it carries the text itself, so it has no lifetime
1112/// parameter and can be stored in a struct, returned from a function, or
1113/// handed to an FFI object without the caller keeping the source alive by hand.
1114/// That is the shape language bindings need, and it removes the only reason
1115/// they would otherwise have had to reach for `unsafe`.
1116///
1117/// ```rust
1118/// use synx_core::synxl::SynxlReaderOwned;
1119///
1120/// fn open() -> SynxlReaderOwned {
1121///     // The `String` is moved into the reader; nothing outlives it.
1122///     SynxlReaderOwned::new(String::from("!synxl 1\n!fields a\n1\n2\n")).unwrap()
1123/// }
1124///
1125/// let records: Vec<_> = open().map(|r| r.unwrap()).collect();
1126/// assert_eq!(records.len(), 2);
1127/// ```
1128#[derive(Debug)]
1129pub struct SynxlReaderOwned {
1130    text: String,
1131    core: ReaderCore,
1132}
1133
1134impl SynxlReaderOwned {
1135    /// Open a reader over an owned document, validating the prologue (§4.1).
1136    pub fn new(text: String) -> Result<Self, SynxlError> {
1137        Self::with_options(text, SynxlOptions::default())
1138    }
1139
1140    /// Open an owning reader with explicit options (§8.4).
1141    pub fn with_options(text: String, opts: SynxlOptions) -> Result<Self, SynxlError> {
1142        let core = ReaderCore::start(&text, opts)?;
1143        Ok(Self { text, core })
1144    }
1145
1146    /// Declared format version (§4.1).
1147    pub fn version(&self) -> u32 {
1148        self.core.version
1149    }
1150
1151    /// Field lists seen so far, in declaration order.
1152    pub fn field_lists(&self) -> &[FieldList] {
1153        &self.core.field_lists
1154    }
1155
1156    /// The field list currently in effect, if any (§4.2).
1157    pub fn field_list(&self) -> Option<&FieldList> {
1158        self.core.current.map(|i| &self.core.field_lists[i])
1159    }
1160
1161    /// Consume the reader, keeping the field lists it collected.
1162    pub fn into_field_lists(self) -> Vec<FieldList> {
1163        self.core.field_lists
1164    }
1165
1166    /// Diagnostics recorded after the last record (§11.2). See
1167    /// [`SynxlReader::trailing_diagnostics`].
1168    pub fn trailing_diagnostics(&self) -> &[Diagnostic] {
1169        &self.core.pending_diagnostics
1170    }
1171
1172    /// The document being read.
1173    pub fn text(&self) -> &str {
1174        &self.text
1175    }
1176
1177    /// Consume the reader and give the document back.
1178    pub fn into_text(self) -> String {
1179        self.text
1180    }
1181}
1182
1183impl Iterator for SynxlReaderOwned {
1184    type Item = Result<SynxlRecord, SynxlError>;
1185
1186    fn next(&mut self) -> Option<Self::Item> {
1187        self.core.next_item(&self.text)
1188    }
1189}
1190
1191// ─── §15.1 Streaming from `io::BufRead` ──────────────────────
1192
1193/// What can go wrong while streaming from an [`io::BufRead`].
1194///
1195/// The two arms are kept apart deliberately: a [`SynxlError`] says the document
1196/// is malformed and is the same verdict every conforming implementation must
1197/// reach, while an [`io::Error`] says nothing about the document at all — the
1198/// disk, socket, or pipe failed. Folding the second into the first would make
1199/// a transient network hiccup indistinguishable from a spec violation.
1200#[derive(Debug)]
1201pub enum SynxlStreamError {
1202    /// A §11.1 hard error in the document.
1203    Format(SynxlError),
1204    /// The underlying reader failed, or produced bytes that are not UTF-8 (§3.1).
1205    Io(std::io::Error),
1206}
1207
1208impl SynxlStreamError {
1209    /// The format error, if this is one.
1210    pub fn as_format(&self) -> Option<&SynxlError> {
1211        match self {
1212            SynxlStreamError::Format(e) => Some(e),
1213            SynxlStreamError::Io(_) => None,
1214        }
1215    }
1216
1217    /// The I/O error, if this is one.
1218    pub fn as_io(&self) -> Option<&std::io::Error> {
1219        match self {
1220            SynxlStreamError::Io(e) => Some(e),
1221            SynxlStreamError::Format(_) => None,
1222        }
1223    }
1224}
1225
1226impl fmt::Display for SynxlStreamError {
1227    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1228        match self {
1229            SynxlStreamError::Format(e) => write!(f, "{}", e),
1230            SynxlStreamError::Io(e) => write!(f, "I/O error while reading SYNXL: {}", e),
1231        }
1232    }
1233}
1234
1235impl std::error::Error for SynxlStreamError {
1236    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
1237        match self {
1238            SynxlStreamError::Format(e) => Some(e),
1239            SynxlStreamError::Io(e) => Some(e),
1240        }
1241    }
1242}
1243
1244impl From<SynxlError> for SynxlStreamError {
1245    fn from(e: SynxlError) -> Self {
1246        SynxlStreamError::Format(e)
1247    }
1248}
1249
1250impl From<std::io::Error> for SynxlStreamError {
1251    fn from(e: std::io::Error) -> Self {
1252        SynxlStreamError::Io(e)
1253    }
1254}
1255
1256/// Streaming record reader over an [`io::BufRead`] — the reader §15.1 asks for.
1257///
1258/// [`SynxlReader`] and [`SynxlReaderOwned`] stream *records* out of a document
1259/// that is already in memory; this one streams the *document* too. §13
1260/// deliberately removed the whole-file byte cap ("datasets are routinely
1261/// gigabytes"), which only means something if a reader never has to hold the
1262/// file, and §3.4 made record boundaries decidable from a single byte precisely
1263/// so that buffering one record is enough.
1264///
1265/// Live memory is therefore one record — its line plus its block — and
1266/// `MAX_SYNXL_RECORD_BYTES` (§13) is the real bound on it: an oversized record
1267/// is cut at the same byte offset the in-memory readers cut it at, reported
1268/// with the same `RecordTruncated` diagnostic, and the rest of the document
1269/// keeps parsing.
1270///
1271/// Parsing is the same code as the other two readers; only the framing — how
1272/// bytes become a record line and a block — differs.
1273///
1274/// ```rust
1275/// use std::io::Cursor;
1276/// use synx_core::synxl::SynxlStreamReader;
1277///
1278/// let src = Cursor::new("!synxl 1\n!fields a ; b\n1 ; 2\n3 ; 4\n");
1279/// let mut n = 0;
1280/// for rec in SynxlStreamReader::new(src).unwrap() {
1281///     assert!(rec.unwrap().diagnostics.is_empty());
1282///     n += 1;
1283/// }
1284/// assert_eq!(n, 2);
1285/// ```
1286#[derive(Debug)]
1287pub struct SynxlStreamReader<R: std::io::BufRead> {
1288    inner: R,
1289    core: ReaderCore,
1290    /// Byte scratch for the line being read.
1291    scratch: Vec<u8>,
1292    /// The current line, `CR`/`LF` excluded.
1293    cur: String,
1294    /// 1-based number of `cur`.
1295    cur_no: usize,
1296    /// Bytes the §13 cap dropped from `cur`.
1297    cur_dropped: usize,
1298    /// The indent-0 line that ended the previous record's block, pushed back.
1299    lookahead: Option<(String, usize, usize)>,
1300    /// 1-based number of the last line read from `inner`.
1301    line_no: usize,
1302    /// The record being assembled (§13 bounds both buffers).
1303    record: String,
1304    block: String,
1305    /// Blank lines seen inside a block, held back until content follows so a
1306    /// trailing run of them stays out of the block (§9.1).
1307    deferred: String,
1308    done: bool,
1309}
1310
1311impl<R: std::io::BufRead> SynxlStreamReader<R> {
1312    /// Open a streaming reader, validating the prologue (§4.1).
1313    pub fn new(inner: R) -> Result<Self, SynxlStreamError> {
1314        Self::with_options(inner, SynxlOptions::default())
1315    }
1316
1317    /// Open a streaming reader with explicit options (§8.4).
1318    pub fn with_options(inner: R, opts: SynxlOptions) -> Result<Self, SynxlStreamError> {
1319        let mut reader = Self {
1320            inner,
1321            core: ReaderCore::empty(opts),
1322            scratch: Vec::with_capacity(256),
1323            cur: String::with_capacity(256),
1324            cur_no: 0,
1325            cur_dropped: 0,
1326            lookahead: None,
1327            line_no: 0,
1328            record: String::new(),
1329            block: String::new(),
1330            deferred: String::new(),
1331            done: false,
1332        };
1333        reader.read_prologue()?;
1334        Ok(reader)
1335    }
1336
1337    /// Declared format version (§4.1).
1338    pub fn version(&self) -> u32 {
1339        self.core.version
1340    }
1341
1342    /// Field lists seen so far, in declaration order.
1343    pub fn field_lists(&self) -> &[FieldList] {
1344        &self.core.field_lists
1345    }
1346
1347    /// The field list currently in effect, if any (§4.2).
1348    pub fn field_list(&self) -> Option<&FieldList> {
1349        self.core.current.map(|i| &self.core.field_lists[i])
1350    }
1351
1352    /// Consume the reader, keeping the field lists it collected.
1353    pub fn into_field_lists(self) -> Vec<FieldList> {
1354        self.core.field_lists
1355    }
1356
1357    /// Diagnostics recorded after the last record (§11.2). See
1358    /// [`SynxlReader::trailing_diagnostics`].
1359    pub fn trailing_diagnostics(&self) -> &[Diagnostic] {
1360        &self.core.pending_diagnostics
1361    }
1362
1363    /// Consume the reader and give the underlying source back.
1364    pub fn into_inner(self) -> R {
1365        self.inner
1366    }
1367
1368    /// Advance `cur` to the next line. `Ok(false)` at end of input.
1369    fn advance(&mut self) -> Result<bool, SynxlStreamError> {
1370        if let Some((text, no, dropped)) = self.lookahead.take() {
1371            self.cur = text;
1372            self.cur_no = no;
1373            self.cur_dropped = dropped;
1374            return Ok(true);
1375        }
1376        let dropped =
1377            match read_capped_line(&mut self.inner, &mut self.scratch, MAX_SYNXL_RECORD_BYTES)? {
1378                Some(d) => d,
1379                None => return Ok(false),
1380            };
1381        // §13 — a cut must land on a valid UTF-8 boundary; the cap can only
1382        // have split a scalar, so at most three bytes come back off.
1383        if dropped > 0 {
1384            for _ in 0..3 {
1385                if std::str::from_utf8(&self.scratch).is_ok() {
1386                    break;
1387                }
1388                self.scratch.pop();
1389            }
1390        }
1391        let text = std::str::from_utf8(&self.scratch).map_err(|e| {
1392            std::io::Error::new(
1393                std::io::ErrorKind::InvalidData,
1394                format!("line {} is not valid UTF-8 (§3.1): {}", self.line_no + 1, e),
1395            )
1396        })?;
1397        self.cur.clear();
1398        // §3.1 — a byte order mark at the start of input is ignored.
1399        self.cur.push_str(if self.line_no == 0 {
1400            text.strip_prefix('\u{feff}').unwrap_or(text)
1401        } else {
1402            text
1403        });
1404        self.line_no += 1;
1405        self.cur_no = self.line_no;
1406        self.cur_dropped = dropped;
1407        Ok(true)
1408    }
1409
1410    /// Push the current line back so the next `advance` returns it again.
1411    fn push_back(&mut self) {
1412        self.lookahead = Some((std::mem::take(&mut self.cur), self.cur_no, self.cur_dropped));
1413    }
1414
1415    /// §4.1 — the first non-empty, non-comment line MUST be the prologue.
1416    fn read_prologue(&mut self) -> Result<(), SynxlStreamError> {
1417        loop {
1418            if !self.advance()? {
1419                return Err(ReaderCore::missing_prologue(self.line_no).into());
1420            }
1421            let trimmed = self.cur.trim();
1422            if self.core.prologue_skip(trimmed) {
1423                continue;
1424            }
1425            self.core.version = parse_prologue(trimmed, self.cur_no)?;
1426            return Ok(());
1427        }
1428    }
1429
1430    /// Advance to the next record, framing it into `record` / `block`.
1431    fn next_record(&mut self) -> Result<Option<SynxlRecord>, SynxlStreamError> {
1432        loop {
1433            if !self.advance()? {
1434                return Ok(None);
1435            }
1436            let trimmed = self.cur.trim();
1437            if trimmed.is_empty() {
1438                continue;
1439            }
1440            // §4.3 — a `###` block comment ignores every line until the closing
1441            // marker, indent included; an ignored line is not an orphan.
1442            if self.core.in_block_comment {
1443                if trimmed == "###" {
1444                    self.core.in_block_comment = false;
1445                }
1446                continue;
1447            }
1448            // §3.4 — indent > 0 with no record open is an orphan (§11.2).
1449            if self.cur.len() - self.cur.trim_start().len() > 0 {
1450                let no = self.cur_no;
1451                self.core.note_orphan(trimmed, no);
1452                continue;
1453            }
1454            if let LineKind::Skip = self.core.classify(trimmed, self.cur_no)? {
1455                continue;
1456            }
1457
1458            let record_no = self.cur_no;
1459            let fl_idx = self.core.require_field_list(record_no)?;
1460            self.record.clear();
1461            self.record.push_str(&self.cur);
1462            // §13 — the budget is the record line plus its block, and the line
1463            // is kept first: it carries the inline fields.
1464            let mut total = self.record.len() + self.cur_dropped;
1465            self.block.clear();
1466            self.deferred.clear();
1467            let mut block_first_no = 0usize;
1468
1469            // §9.1 — the block runs to the next non-empty line at indent 0.
1470            loop {
1471                if !self.advance()? {
1472                    break;
1473                }
1474                if self.cur.trim().is_empty() {
1475                    // §3.5 — blank lines never terminate a block, but a
1476                    // trailing run of them is not part of it either, so they
1477                    // wait here until content proves they were interior.
1478                    if !self.block.is_empty() {
1479                        self.deferred.push('\n');
1480                        self.deferred.push_str(&self.cur);
1481                    }
1482                    continue;
1483                }
1484                if self.cur.len() - self.cur.trim_start().len() == 0 {
1485                    self.push_back();
1486                    break;
1487                }
1488                if block_first_no == 0 {
1489                    block_first_no = self.cur_no;
1490                }
1491                let sep = usize::from(!self.block.is_empty());
1492                total += self.deferred.len() + sep + self.cur.len() + self.cur_dropped;
1493                // Cut at exactly the offset the in-memory readers cut at, so
1494                // the two paths agree byte-for-byte on a truncated record.
1495                let room = (MAX_SYNXL_RECORD_BYTES + self.deferred.len())
1496                    .saturating_sub(self.record.len() + self.block.len());
1497                if room > self.deferred.len() + sep {
1498                    let budget = room - self.deferred.len() - sep;
1499                    self.block.push_str(&self.deferred);
1500                    if sep == 1 {
1501                        self.block.push('\n');
1502                    }
1503                    let piece_len = truncate_utf8(&self.cur, budget).len();
1504                    self.block.push_str(&self.cur[..piece_len]);
1505                }
1506                self.deferred.clear();
1507            }
1508
1509            let truncated_total = if total > MAX_SYNXL_RECORD_BYTES { Some(total) } else { None };
1510            let frame = RecordFrame {
1511                line: &self.record,
1512                line_no: record_no,
1513                block: &self.block,
1514                block_first_no,
1515                truncated_total,
1516            };
1517            return self.core.build(fl_idx, frame).map(Some).map_err(Into::into);
1518        }
1519    }
1520}
1521
1522impl<R: std::io::BufRead> Iterator for SynxlStreamReader<R> {
1523    type Item = Result<SynxlRecord, SynxlStreamError>;
1524
1525    fn next(&mut self) -> Option<Self::Item> {
1526        if self.done {
1527            return None;
1528        }
1529        match self.next_record() {
1530            Ok(Some(rec)) => Some(Ok(rec)),
1531            Ok(None) => {
1532                self.done = true;
1533                None
1534            }
1535            Err(err) => {
1536                // §11.1 — a hard error ends the document; an I/O failure means
1537                // there is nothing left to read from either.
1538                self.done = true;
1539                Some(Err(err))
1540            }
1541        }
1542    }
1543}
1544
1545/// Read one line into `out`, keeping at most `cap` bytes of it.
1546///
1547/// The trailing `LF` and a `CR` before it are not stored (§3.2). Returns the
1548/// number of bytes dropped by the cap, or `None` at end of input — which is
1549/// what bounds live memory when a hostile document has no newline in it.
1550fn read_capped_line<R: std::io::BufRead>(
1551    reader: &mut R,
1552    out: &mut Vec<u8>,
1553    cap: usize,
1554) -> std::io::Result<Option<usize>> {
1555    out.clear();
1556    let mut dropped = 0usize;
1557    let mut saw_any = false;
1558    loop {
1559        let (consumed, done) = {
1560            let available = match reader.fill_buf() {
1561                Ok(b) => b,
1562                Err(ref e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
1563                Err(e) => return Err(e),
1564            };
1565            if available.is_empty() {
1566                break;
1567            }
1568            saw_any = true;
1569            match memchr(b'\n', available) {
1570                Some(i) => {
1571                    push_capped(out, &available[..i], cap, &mut dropped);
1572                    (i + 1, true)
1573                }
1574                None => {
1575                    let len = available.len();
1576                    push_capped(out, available, cap, &mut dropped);
1577                    (len, false)
1578                }
1579            }
1580        };
1581        reader.consume(consumed);
1582        if done {
1583            break;
1584        }
1585    }
1586    if !saw_any {
1587        return Ok(None);
1588    }
1589    if out.last() == Some(&b'\r') {
1590        out.pop();
1591    }
1592    Ok(Some(dropped))
1593}
1594
1595fn push_capped(out: &mut Vec<u8>, chunk: &[u8], cap: usize, dropped: &mut usize) {
1596    let room = cap.saturating_sub(out.len());
1597    let take = room.min(chunk.len());
1598    out.extend_from_slice(&chunk[..take]);
1599    *dropped += chunk.len() - take;
1600}
1601
1602// ─── Whole-document parse ────────────────────────────────────
1603
1604/// Parse a whole SYNXL document (§4–§10).
1605///
1606/// Hard errors (§11.1) are returned as `Err`; recoverable observations live in
1607/// [`SynxlDocument::diagnostics`] (§11.2).
1608pub fn parse_lines(text: &str) -> Result<SynxlDocument, SynxlError> {
1609    parse_lines_with(text, &SynxlOptions::default())
1610}
1611
1612/// [`parse_lines`] with explicit options (§8.4).
1613pub fn parse_lines_with(text: &str, opts: &SynxlOptions) -> Result<SynxlDocument, SynxlError> {
1614    let mut reader = SynxlReader::with_options(text, opts.clone())?;
1615    let mut records = Vec::new();
1616    let mut record_field_lists = Vec::new();
1617    let mut record_lines = Vec::new();
1618    let mut diagnostics = Vec::new();
1619
1620    loop {
1621        let item = match reader.next() {
1622            Some(item) => item,
1623            None => break,
1624        };
1625        let mut rec = item?;
1626        // §13 — the record cap applies to the in-memory parse only.
1627        if records.len() >= MAX_SYNXL_RECORDS {
1628            return Err(SynxlError::new(
1629                SynxlErrorKind::LimitExceeded,
1630                rec.line,
1631                format!("more than {} records in an in-memory parse", MAX_SYNXL_RECORDS),
1632            ));
1633        }
1634        records.push(rec.value);
1635        record_field_lists.push(rec.field_list);
1636        record_lines.push(rec.line);
1637        diagnostics.append(&mut rec.diagnostics);
1638    }
1639
1640    // §11.2 — orphan lines after the last record have no record to attach to,
1641    // but must still be reported.
1642    diagnostics.extend_from_slice(reader.trailing_diagnostics());
1643
1644    let version = reader.version();
1645    Ok(SynxlDocument {
1646        version,
1647        records,
1648        field_lists: reader.into_field_lists(),
1649        record_field_lists,
1650        record_lines,
1651        diagnostics,
1652    })
1653}
1654
1655/// Map each top-level block key to the 1-based source line that declares it.
1656///
1657/// §11.2 requires `UnknownBlockKey` / `BlockFieldNotDeclared` to report the
1658/// line inside the block, but the SYNX parser returns no positions. The keys
1659/// that end up at the root of the embedded document are those at the block's
1660/// shallowest indent (SYNX §8.6 stack repair), so re-scanning that one level is
1661/// enough — and it only runs when a diagnostic is actually being emitted.
1662fn block_key_lines(block: &str, first_line_no: usize) -> HashMap<&str, usize> {
1663    // A key lands at the root of the embedded document when its indent is at
1664    // or below every indent seen before it: the first line opens the root
1665    // level, and SYNX §8.6 stack repair pulls any later dedent back up to it.
1666    // Comparing against the block's *overall* minimum instead would skip the
1667    // deeper-but-still-root keys, and their diagnostics would then fall back
1668    // to the record line — the divergence §11.2 forbids.
1669    let mut running_min = usize::MAX;
1670    let mut map: HashMap<&str, usize> = HashMap::new();
1671    for (i, line) in block.split('\n').enumerate() {
1672        let line = line.strip_suffix('\r').unwrap_or(line);
1673        let trimmed = line.trim();
1674        if trimmed.is_empty() {
1675            continue;
1676        }
1677        let indent = line.len() - line.trim_start().len();
1678        if indent > running_min {
1679            continue;
1680        }
1681        running_min = indent;
1682        // Lines SYNX never turns into a key (SYNX §7) plus `!` lines, which
1683        // SYNXL discards inside a block (§9.4).
1684        if trimmed.starts_with("- ") || trimmed.starts_with('!') {
1685            continue;
1686        }
1687        match trimmed.as_bytes()[0] {
1688            b'[' | b':' | b'-' | b'#' | b'/' | b'(' => continue,
1689            _ => {}
1690        }
1691        let key_end = trimmed
1692            .find(|c: char| c == ' ' || c == '\t' || c == '[' || c == ':' || c == '(')
1693            .unwrap_or(trimmed.len());
1694        map.entry(&trimmed[..key_end]).or_insert(first_line_no + i);
1695    }
1696    map
1697}
1698
1699// ─── §4.1 Prologue ───────────────────────────────────────────
1700
1701fn parse_prologue(trimmed: &str, line: usize) -> Result<u32, SynxlError> {
1702    let rest = match trimmed.strip_prefix("!synxl") {
1703        Some(r) if starts_with_wsp(r) => r,
1704        _ => {
1705            return Err(SynxlError::new(
1706                SynxlErrorKind::MissingPrologue,
1707                line,
1708                format!("expected `!synxl <version>`, found `{}`", elide(trimmed)),
1709            ))
1710        }
1711    };
1712    let version = rest.trim();
1713    // The grammar is `"!synxl" 1*WSP version LF` — nothing may follow.
1714    if version.is_empty() || !version.bytes().all(|b| b.is_ascii_digit()) {
1715        return Err(SynxlError::new(
1716            SynxlErrorKind::MissingPrologue,
1717            line,
1718            format!("prologue version must be a decimal integer, found `{}`", elide(version)),
1719        ));
1720    }
1721    match version.parse::<u32>() {
1722        Ok(v) if v == SYNXL_VERSION => Ok(v),
1723        _ => Err(SynxlError::new(
1724            SynxlErrorKind::UnsupportedVersion,
1725            line,
1726            format!("SYNXL version `{}` is not supported (this build implements {})", version, SYNXL_VERSION),
1727        )),
1728    }
1729}
1730
1731// ─── §5 Field-list parsing ───────────────────────────────────
1732
1733/// Parse the text following `!fields` into a [`FieldList`] (§5).
1734///
1735/// `source` is the whole `!fields` line, kept verbatim for tools that re-emit
1736/// it (see [`FieldList::source`]).
1737fn parse_field_list(rest: &str, source: &str, line: usize) -> Result<FieldList, SynxlError> {
1738    if rest.is_empty() {
1739        return Err(SynxlError::new(
1740            SynxlErrorKind::MalformedFieldList,
1741            line,
1742            "`!fields` declares no fields",
1743        ));
1744    }
1745
1746    let mut fields: Vec<FieldDecl> = Vec::new();
1747    for decl in rest.split(';') {
1748        let decl = decl.trim();
1749        if decl.is_empty() {
1750            return Err(SynxlError::new(
1751                SynxlErrorKind::MalformedFieldList,
1752                line,
1753                "empty field declaration",
1754            ));
1755        }
1756        if fields.len() >= MAX_SYNXL_FIELDS {
1757            return Err(SynxlError::new(
1758                SynxlErrorKind::LimitExceeded,
1759                line,
1760                format!("more than {} fields in one field list", MAX_SYNXL_FIELDS),
1761            ));
1762        }
1763        let field = parse_field_decl(decl, line)?;
1764        // §5.1 — duplicates are rejected rather than resolved.
1765        if fields.iter().any(|f| f.name == field.name) {
1766            return Err(SynxlError::new(
1767                SynxlErrorKind::DuplicateField,
1768                line,
1769                format!("duplicate field name `{}`", field.name),
1770            ));
1771        }
1772        fields.push(field);
1773    }
1774
1775    let list = FieldList::new(fields);
1776    // §5.3.4 — a zero-arity field list has no representation: its record line
1777    // would be empty, and §3.5 makes empty lines invisible, so records would
1778    // have no detectable boundary.
1779    if list.arity() == 0 {
1780        return Err(SynxlError::new(
1781            SynxlErrorKind::ZeroArity,
1782            line,
1783            "field list has arity 0 — every field is declared [block]; at least one inline field is required",
1784        ));
1785    }
1786    let mut list = list;
1787    list.line = line;
1788    list.source = source.to_string();
1789    Ok(list)
1790}
1791
1792/// `name [ "(" type ")" ] [ "[" constraints "]" ]` (§5).
1793fn parse_field_decl(decl: &str, line: usize) -> Result<FieldDecl, SynxlError> {
1794    let bytes = decl.as_bytes();
1795    let len = bytes.len();
1796
1797    // §5.1 — the name runs until `[`, `(`, `:`, or whitespace.
1798    let mut pos = 0usize;
1799    while pos < len {
1800        let ch = bytes[pos];
1801        if ch == b' ' || ch == b'\t' || ch == b'[' || ch == b'(' || ch == b':' {
1802            break;
1803        }
1804        pos += 1;
1805    }
1806    let name = &decl[..pos];
1807    if name.is_empty() {
1808        return Err(SynxlError::new(
1809            SynxlErrorKind::MalformedFieldList,
1810            line,
1811            format!("field declaration `{}` has no name", elide(decl)),
1812        ));
1813    }
1814    if name.len() > MAX_SYNXL_FIELD_NAME_BYTES {
1815        return Err(SynxlError::new(
1816            SynxlErrorKind::LimitExceeded,
1817            line,
1818            format!(
1819                "field name is {} bytes, limit is {}",
1820                name.len(),
1821                MAX_SYNXL_FIELD_NAME_BYTES
1822            ),
1823        ));
1824    }
1825
1826    // Optional `(type)`.
1827    let mut type_hint = None;
1828    if pos < len && bytes[pos] == b'(' {
1829        let start = pos + 1;
1830        match decl[start..].find(')') {
1831            Some(rel) => {
1832                type_hint = Some(decl[start..start + rel].trim().to_string());
1833                pos = start + rel + 1;
1834            }
1835            None => {
1836                return Err(SynxlError::new(
1837                    SynxlErrorKind::MalformedFieldList,
1838                    line,
1839                    format!("unterminated `(` in field declaration `{}`", elide(decl)),
1840                ))
1841            }
1842        }
1843    }
1844
1845    // Optional `[constraints]` — balanced scan, so `pattern:^[A-Z]$` survives.
1846    let mut constraints = Constraints::default();
1847    let mut block = false;
1848    if pos < len && bytes[pos] == b'[' {
1849        let cstart = pos + 1;
1850        let mut depth = 1usize;
1851        let mut scan = cstart;
1852        while scan < len {
1853            match bytes[scan] {
1854                b'[' => depth += 1,
1855                b']' => {
1856                    depth -= 1;
1857                    if depth == 0 {
1858                        break;
1859                    }
1860                }
1861                _ => {}
1862            }
1863            scan += 1;
1864        }
1865        if depth != 0 {
1866            return Err(SynxlError::new(
1867                SynxlErrorKind::MalformedFieldList,
1868                line,
1869                format!("unterminated `[` in field declaration `{}`", elide(decl)),
1870            ));
1871        }
1872        let raw = &decl[cstart..scan];
1873        // §5.2 — reuse the SYNX constraint parser verbatim.
1874        constraints = parser::parse_constraints(raw);
1875        // §5.3 — `block` is SYNXL-only, so the SYNX parser drops it as an
1876        // unrecognised bare flag; pick it up here.
1877        block = raw
1878            .split(',')
1879            .map(|p| p.trim())
1880            .any(|p| p == "block");
1881        pos = scan + 1;
1882    }
1883
1884    // Nothing but whitespace may follow.
1885    let tail = decl[pos..].trim();
1886    if !tail.is_empty() {
1887        // §5.2 — marker chains are reserved for a future version.
1888        if tail.starts_with(':') {
1889            return Err(SynxlError::new(
1890                SynxlErrorKind::MarkerChain,
1891                line,
1892                format!("marker chain `{}` is not allowed in a field declaration", elide(tail)),
1893            ));
1894        }
1895        return Err(SynxlError::new(
1896            SynxlErrorKind::MalformedFieldList,
1897            line,
1898            format!("trailing `{}` in field declaration `{}`", elide(tail), elide(decl)),
1899        ));
1900    }
1901
1902    // §8.3 — a dataset whose parse result varies between reads is not
1903    // interchangeable, so the non-deterministic hints are rejected outright.
1904    for hint in [type_hint.as_deref(), constraints.type_name.as_deref()]
1905        .into_iter()
1906        .flatten()
1907    {
1908        if is_non_deterministic_hint(hint) {
1909            return Err(SynxlError::new(
1910                SynxlErrorKind::NonDeterministicHint,
1911                line,
1912                format!("non-deterministic type hint `{}` on field `{}`", hint, name),
1913            ));
1914        }
1915    }
1916
1917    // §5.3.2 — the shape of a block value comes from the embedded document.
1918    if block && (type_hint.is_some() || constraints.type_name.is_some()) {
1919        return Err(SynxlError::new(
1920            SynxlErrorKind::BlockWithType,
1921            line,
1922            format!("field `{}` combines `block` with a type", name),
1923        ));
1924    }
1925
1926    Ok(FieldDecl {
1927        name: name.to_string(),
1928        type_hint,
1929        constraints,
1930        block,
1931    })
1932}
1933
1934fn is_non_deterministic_hint(hint: &str) -> bool {
1935    matches!(hint, "random" | "random:int" | "random:float" | "random:bool")
1936}
1937
1938// ─── §7.1 Record-line splitting ──────────────────────────────
1939
1940/// One inline part: the text plus whether it came from a quoted run (§7.4).
1941#[derive(Debug, Clone, Copy, PartialEq)]
1942struct Part<'a> {
1943    text: &'a str,
1944    quoted: bool,
1945}
1946
1947/// Splits a record line on `;` while recognising quotes in the same pass (§7.1).
1948///
1949/// The algorithm is normative and reproduced literally: skip horizontal
1950/// whitespace, try a quoted run, and fall back to an unquoted part on any
1951/// failure — no opening quote, no matching close, or garbage after the close.
1952struct PartSplitter<'a> {
1953    s: &'a str,
1954    b: &'a [u8],
1955    pos: usize,
1956    finished: bool,
1957}
1958
1959impl<'a> PartSplitter<'a> {
1960    fn new(s: &'a str) -> Self {
1961        Self { s, b: s.as_bytes(), pos: 0, finished: false }
1962    }
1963}
1964
1965impl<'a> Iterator for PartSplitter<'a> {
1966    type Item = Part<'a>;
1967
1968    fn next(&mut self) -> Option<Part<'a>> {
1969        if self.finished {
1970            return None;
1971        }
1972        let len = self.b.len();
1973        let mut i = self.pos;
1974
1975        // 1. Skip spaces and horizontal tabs.
1976        while i < len && (self.b[i] == b' ' || self.b[i] == b'\t') {
1977            i += 1;
1978        }
1979
1980        // 2. Quoted candidate.
1981        if i < len && (self.b[i] == b'"' || self.b[i] == b'\'') {
1982            let quote = self.b[i];
1983            if let Some(rel) = memchr(quote, &self.b[i + 1..]) {
1984                let close = i + 1 + rel;
1985                let mut j = close + 1;
1986                while j < len && (self.b[j] == b' ' || self.b[j] == b'\t') {
1987                    j += 1;
1988                }
1989                if j >= len || self.b[j] == b';' {
1990                    // §7.4 — the value is the text strictly between the quotes,
1991                    // untrimmed and uninterpreted.
1992                    let part = Part { text: &self.s[i + 1..close], quoted: true };
1993                    if j >= len {
1994                        self.finished = true;
1995                        self.pos = len;
1996                    } else {
1997                        self.pos = j + 1;
1998                    }
1999                    return Some(part);
2000                }
2001            }
2002        }
2003
2004        // 3. Unquoted: up to the next `;` or end of line; quotes inside are
2005        //    ordinary content. 4. Trimmed of leading/trailing space and tab.
2006        let end = match memchr(b';', &self.b[i..]) {
2007            Some(rel) => i + rel,
2008            None => len,
2009        };
2010        let text = trim_wsp(&self.s[i..end]);
2011        if end >= len {
2012            self.finished = true;
2013            self.pos = len;
2014        } else {
2015            self.pos = end + 1;
2016        }
2017        Some(Part { text, quoted: false })
2018    }
2019}
2020
2021// ─── §8 Casting ──────────────────────────────────────────────
2022
2023/// Cast one inline part for `field`, recording a diagnostic on failure.
2024fn cast_part(
2025    field: &FieldDecl,
2026    part: Part<'_>,
2027    record_index: usize,
2028    line: usize,
2029    diagnostics: &mut Vec<Diagnostic>,
2030) -> Value {
2031    // §7.4 — a quoted part is literal inner text: no casting at all.
2032    if part.quoted {
2033        return Value::String(part.text.to_string());
2034    }
2035    // §7.2 — an empty unquoted part is null, not an empty string.
2036    if part.text.is_empty() {
2037        return Value::Null;
2038    }
2039    match field.type_name() {
2040        // §8.2 — typed casting; a failure nulls the cell but keeps the row.
2041        Some(hint) => match cast_typed_checked(part.text, hint) {
2042            Some(v) => v,
2043            None => {
2044                diagnostics.push(Diagnostic {
2045                    record_index,
2046                    line,
2047                    kind: DiagnosticKind::CastFailed,
2048                    message: format!(
2049                        "field `{}`: `{}` is not a valid {}",
2050                        field.name,
2051                        elide(part.text),
2052                        hint
2053                    ),
2054                });
2055                Value::Null
2056            }
2057        },
2058        // §8.1 — automatic casting.
2059        None => cast_inline(part.text),
2060    }
2061}
2062
2063/// SYNX §8.3 automatic casting minus quote stripping (§8.1 + §7.1 step 3).
2064///
2065/// A part that survived §7.1 as *unquoted* keeps its quote characters as
2066/// ordinary content, so SYNX's "surrounded by quotes → literal inner text"
2067/// step must not run a second time; otherwise `"a"b"` would silently lose two
2068/// characters and break the §14.1 round-trip.
2069fn cast_inline(raw: &str) -> Value {
2070    if is_quote_wrapped(raw) {
2071        return Value::String(raw.to_string());
2072    }
2073    parser::cast(raw)
2074}
2075
2076/// SYNX §8.3 typed casting with explicit failure (§8.2).
2077fn cast_typed_checked(raw: &str, hint: &str) -> Option<Value> {
2078    match hint {
2079        "int" => raw.parse::<i64>().ok().map(Value::Int),
2080        // Non-finite floats have no JSON form, so they count as failures
2081        // rather than becoming a `null` with no diagnostic attached.
2082        "float" => raw.parse::<f64>().ok().filter(|f| f.is_finite()).map(Value::Float),
2083        "bool" => match raw {
2084            "true" => Some(Value::Bool(true)),
2085            "false" => Some(Value::Bool(false)),
2086            _ => None,
2087        },
2088        "string" => Some(Value::String(raw.to_string())),
2089        // SYNX §8.3: an unknown hint falls back to automatic casting, which
2090        // cannot fail.
2091        _ => Some(cast_inline(raw)),
2092    }
2093}
2094
2095// ─── §8.4 Validation (opt-in) ────────────────────────────────
2096
2097/// Mirror of the engine's constraint enforcement, reported instead of applied.
2098fn check_constraints(key: &str, value: &Value, c: &Constraints) -> Option<String> {
2099    if c.required {
2100        let empty = matches!(value, Value::Null)
2101            || matches!(value, Value::String(s) if s.is_empty());
2102        if empty {
2103            return Some(format!("`{}` is required", key));
2104        }
2105    }
2106    // An unset optional field is not checked any further.
2107    if matches!(value, Value::Null) {
2108        return None;
2109    }
2110
2111    if let Some(ref type_name) = c.type_name {
2112        let ok = match type_name.as_str() {
2113            "int" => matches!(value, Value::Int(_)),
2114            "float" => matches!(value, Value::Float(_) | Value::Int(_)),
2115            "bool" => matches!(value, Value::Bool(_)),
2116            "string" => matches!(value, Value::String(_)),
2117            _ => true,
2118        };
2119        if !ok {
2120            return Some(format!("`{}` expected type `{}`", key, type_name));
2121        }
2122    }
2123
2124    if let Some(ref enum_values) = c.enum_values {
2125        let as_str = match value {
2126            Value::String(s) => s.clone(),
2127            Value::Int(n) => n.to_string(),
2128            Value::Float(f) => f.to_string(),
2129            Value::Bool(b) => b.to_string(),
2130            _ => String::new(),
2131        };
2132        if !enum_values.contains(&as_str) {
2133            return Some(format!("`{}` must be one of [{}]", key, enum_values.join("|")));
2134        }
2135    }
2136
2137    // Numbers compare by value, strings by length — same rule as the engine.
2138    let num = match value {
2139        Value::Int(n) => Some(*n as f64),
2140        Value::Float(f) => Some(*f),
2141        Value::String(s) if c.min.is_some() || c.max.is_some() => Some(s.len() as f64),
2142        _ => None,
2143    };
2144    if let Some(n) = num {
2145        if let Some(min) = c.min {
2146            if n < min {
2147                return Some(format!("`{}` value {} is below min {}", key, n, min));
2148            }
2149        }
2150        if let Some(max) = c.max {
2151            if n > max {
2152                return Some(format!("`{}` value {} exceeds max {}", key, n, max));
2153            }
2154        }
2155    }
2156
2157    if let Some(ref pattern) = c.pattern {
2158        if pattern.len() <= 256 {
2159            if let Value::String(ref s) = value {
2160                // An invalid regex is skipped silently, matching the engine.
2161                if let Ok(re) = regex::Regex::new(pattern) {
2162                    if !re.is_match(s) {
2163                        return Some(format!("`{}` does not match pattern /{}/", key, pattern));
2164                    }
2165                }
2166            }
2167        }
2168    }
2169
2170    None
2171}
2172
2173// ─── §14 Writer ──────────────────────────────────────────────
2174
2175/// Serialize a document, emitting a `!fields` line per run of records that
2176/// share a field list (§14, §4.2).
2177///
2178/// Fails with [`SynxlErrorKind::Unwritable`] when a value has no SYNXL
2179/// rendering (§14.3). That cannot happen for a document obtained by parsing —
2180/// §14.1 guarantees the round trip — so only a programmatically built value
2181/// can trigger it.
2182pub fn write_document(doc: &SynxlDocument) -> Result<String, SynxlError> {
2183    let mut out = String::with_capacity(doc.records.len() * 64 + 64);
2184    out.push_str("!synxl 1\n");
2185
2186    if doc.records.is_empty() {
2187        // §4.2 — a document still declares its schema even with no rows.
2188        if let Some(fl) = doc.field_lists.first() {
2189            write_group(&mut out, fl.fields(), &[])?;
2190        }
2191        return Ok(out);
2192    }
2193
2194    let mut start = 0usize;
2195    while start < doc.records.len() {
2196        let fl_idx = doc.record_field_lists.get(start).copied().unwrap_or(0);
2197        let mut end = start + 1;
2198        while end < doc.records.len()
2199            && doc.record_field_lists.get(end).copied().unwrap_or(0) == fl_idx
2200        {
2201            end += 1;
2202        }
2203        let empty = FieldList::new(Vec::new());
2204        let fl = doc.field_lists.get(fl_idx).unwrap_or(&empty);
2205        write_group(&mut out, fl.fields(), &doc.records[start..end])?;
2206        start = end;
2207    }
2208    Ok(out)
2209}
2210
2211/// Serialize a single-schema record set (§14). See [`write_document`] for the
2212/// failure condition.
2213pub fn write_lines(fields: &[FieldDecl], records: &[Value]) -> Result<String, SynxlError> {
2214    let mut out = String::with_capacity(records.len() * 64 + 64);
2215    out.push_str("!synxl 1\n");
2216    write_group(&mut out, fields, records)?;
2217    Ok(out)
2218}
2219
2220/// §5.1 / §5.3.4 — reject a field list that would not survive a read back.
2221///
2222/// Field names reach this crate straight from caller data (a dict key, a CSV
2223/// header, a JSON property), so none of §5.1's constraints can be assumed.
2224/// Writing them out unchecked produces documents that either fail to parse or,
2225/// worse, parse into a *different* schema: a `;` in a name silently splits one
2226/// column into two, and a newline splits one record into two.
2227fn validate_writable_fields(fields: &[FieldDecl]) -> Result<(), SynxlError> {
2228    if fields.is_empty() {
2229        return Err(SynxlError::new(
2230            SynxlErrorKind::Unwritable,
2231            0,
2232            "a field list must declare at least one field (§5.3.4); an empty one reads back as a malformed field list",
2233        ));
2234    }
2235    if fields.len() > MAX_SYNXL_FIELDS {
2236        return Err(SynxlError::new(
2237            SynxlErrorKind::Unwritable,
2238            0,
2239            format!("field list declares {} fields, over the {} limit (§13)", fields.len(), MAX_SYNXL_FIELDS),
2240        ));
2241    }
2242    let mut seen: HashSet<&str> = HashSet::with_capacity(fields.len());
2243    for field in fields {
2244        let name = field.name.as_str();
2245        if name.is_empty() {
2246            return Err(SynxlError::new(
2247                SynxlErrorKind::Unwritable,
2248                0,
2249                "field name is empty (§5.1.1)",
2250            ));
2251        }
2252        if name.len() > MAX_SYNXL_FIELD_NAME_BYTES {
2253            return Err(SynxlError::new(
2254                SynxlErrorKind::Unwritable,
2255                0,
2256                format!(
2257                    "field name `{}` is {} bytes, over the {} byte limit (§13)",
2258                    elide(name),
2259                    name.len(),
2260                    MAX_SYNXL_FIELD_NAME_BYTES
2261                ),
2262            ));
2263        }
2264        if let Some(bad) = name
2265            .chars()
2266            .find(|c| matches!(c, ';' | '[' | '(' | ':') || c.is_whitespace())
2267        {
2268            return Err(SynxlError::new(
2269                SynxlErrorKind::Unwritable,
2270                0,
2271                format!(
2272                    "field name `{}` contains `{}`, which §5.1.1 excludes; it would read back as a different schema",
2273                    elide(name),
2274                    bad.escape_debug()
2275                ),
2276            ));
2277        }
2278        if !seen.insert(name) {
2279            return Err(SynxlError::new(
2280                SynxlErrorKind::Unwritable,
2281                0,
2282                format!("field name `{}` is declared twice (§5.1.4)", elide(name)),
2283            ));
2284        }
2285    }
2286    Ok(())
2287}
2288fn write_group(
2289    out: &mut String,
2290    fields: &[FieldDecl],
2291    records: &[Value],
2292) -> Result<(), SynxlError> {
2293    validate_writable_fields(fields)?;
2294
2295    // §14.3 — a value that cannot live inline is promoted, which is a property
2296    // of the whole column: block-ness is declared once, in the field list.
2297    let mut block: Vec<bool> = fields
2298        .iter()
2299        .map(|f| {
2300            f.block
2301                || records.iter().any(|r| {
2302                    r.as_object()
2303                        .and_then(|m| m.get(&f.name))
2304                        .map(|v| needs_block(v, f.type_name()))
2305                        .unwrap_or(false)
2306                })
2307        })
2308        .collect();
2309
2310    // §5.3.4 — arity 0 is a hard error, so promotion must leave one column
2311    // inline. The candidate must be a declared-inline column none of whose
2312    // values contain `LF`, `CR`, or `;`; such a value survives as an unquoted
2313    // part byte-for-byte, losing at most significant edge whitespace, which
2314    // §14.1's scope note already exempts. One always exists for a document
2315    // that came from a parse — an inline part can hold none of those bytes.
2316    //
2317    // If none exists (only reachable for a programmatically built value), the
2318    // value has no rendering at all: §14.3 requires rejecting it loudly, and
2319    // emitting the zero-arity document anyway would be the forbidden half of
2320    // that rule — a document this crate's own parser refuses.
2321    if !fields.is_empty() && block.iter().all(|b| *b) {
2322        let candidate = (0..fields.len()).filter(|i| !fields[*i].block).find(|i| {
2323            !records.iter().any(|r| {
2324                matches!(
2325                    r.as_object().and_then(|m| m.get(&fields[*i].name)),
2326                    Some(Value::String(s))
2327                        if s.contains('\n') || s.contains('\r') || s.contains(';')
2328                )
2329            })
2330        });
2331        match candidate {
2332            Some(i) => block[i] = false,
2333            None => {
2334                return Err(SynxlError::new(
2335                    SynxlErrorKind::Unwritable,
2336                    0,
2337                    "every field would have to be promoted to a block, which leaves the field list at arity 0 (§5.3.4); the value has no SYNXL rendering",
2338                ))
2339            }
2340        }
2341    }
2342
2343    out.push_str("!fields ");
2344    for (i, field) in fields.iter().enumerate() {
2345        if i > 0 {
2346            out.push_str("; ");
2347        }
2348        write_field_decl(out, field, block[i]);
2349    }
2350    out.push('\n');
2351
2352    for record in records {
2353        let map = record.as_object();
2354        // §7.2 — an all-null record is written as the single token `;`, which
2355        // is arity-independent and diagnostic-free. Joining empty parts would
2356        // produce an invisible line at arity 1 and a `MissingFields` at 3.
2357        let all_null = fields.iter().enumerate().all(|(i, f)| {
2358            block[i]
2359                || map
2360                    .and_then(|m| m.get(&f.name))
2361                    .map(writes_as_empty)
2362                    .unwrap_or(true)
2363        });
2364        if all_null {
2365            out.push(';');
2366        } else {
2367            let mut first = true;
2368            for (i, field) in fields.iter().enumerate() {
2369                if block[i] {
2370                    continue;
2371                }
2372                if !first {
2373                    out.push_str("; ");
2374                }
2375                first = false;
2376                let value = map.and_then(|m| m.get(&field.name)).unwrap_or(&Value::Null);
2377                write_inline(out, value, field.type_name());
2378            }
2379        }
2380        out.push('\n');
2381
2382        for (i, field) in fields.iter().enumerate() {
2383            if !block[i] {
2384                continue;
2385            }
2386            let value = match map.and_then(|m| m.get(&field.name)) {
2387                Some(v) if !v.is_null() => v,
2388                // §9.2 — an absent block field needs no lines at all.
2389                _ => continue,
2390            };
2391            write_synx_entry(out, &field.name, value, 2, 0);
2392        }
2393    }
2394    Ok(())
2395}
2396
2397fn write_field_decl(out: &mut String, field: &FieldDecl, block: bool) {
2398    out.push_str(&field.name);
2399    // §5.3.2 — a promoted field drops its type; the block document carries the
2400    // shape instead.
2401    if !block {
2402        if let Some(ref hint) = field.type_hint {
2403            out.push('(');
2404            out.push_str(hint);
2405            out.push(')');
2406        }
2407    }
2408
2409    let c = &field.constraints;
2410    let mut parts: Vec<String> = Vec::new();
2411    if !block {
2412        if let Some(ref t) = c.type_name {
2413            parts.push(format!("type:{}", t));
2414        }
2415    }
2416    if c.required {
2417        parts.push("required".to_string());
2418    }
2419    if c.readonly {
2420        parts.push("readonly".to_string());
2421    }
2422    if let Some(min) = c.min {
2423        parts.push(format!("min:{}", trim_float(min)));
2424    }
2425    if let Some(max) = c.max {
2426        parts.push(format!("max:{}", trim_float(max)));
2427    }
2428    if let Some(ref p) = c.pattern {
2429        parts.push(format!("pattern:{}", p));
2430    }
2431    if let Some(ref e) = c.enum_values {
2432        parts.push(format!("enum:{}", e.join("|")));
2433    }
2434    if block {
2435        parts.push("block".to_string());
2436    }
2437    if !parts.is_empty() {
2438        out.push('[');
2439        out.push_str(&parts.join(", "));
2440        out.push(']');
2441    }
2442}
2443
2444/// Does this value render as an empty inline part?
2445///
2446/// `null` does by §14.2, and so does a non-finite float: it has no SYNX
2447/// representation, so §14.3 writes it as an empty part that reads back as
2448/// `null`. Both therefore count towards the §7.2 all-null form — without the
2449/// second case, a single-column record holding `inf` writes an empty line,
2450/// which §3.5 makes invisible and the record disappears on the way back in.
2451fn writes_as_empty(value: &Value) -> bool {
2452    match value {
2453        Value::Null => true,
2454        Value::Float(f) => !f.is_finite(),
2455        _ => false,
2456    }
2457}
2458
2459/// §14.3 — values that cannot be written as an inline part.
2460fn needs_block(value: &Value, hint: Option<&str>) -> bool {
2461    match value {
2462        // Structure has no inline form at all.
2463        Value::Object(_) | Value::Array(_) => true,
2464        Value::String(s) | Value::Secret(s) => {
2465            s.contains('\n')
2466                || s.contains('\r')
2467                // Quoting has no escapes (§7.4/§16.5), so a value that needs
2468                // quoting and contains both quote characters — the `;` + quote
2469                // case of §14.3 among others — has to be promoted.
2470                || (inline_needs_quote(s, hint) && pick_quote(s).is_none())
2471        }
2472        _ => false,
2473    }
2474}
2475
2476/// Would this string be misread if written as a bare inline part? (§14.3)
2477///
2478/// `hint` is the effective type of the column it is going into ([`FieldDecl::type_name`]),
2479/// which decides whether the reader will apply §8.2 typed casting to it.
2480fn inline_needs_quote(s: &str, hint: Option<&str>) -> bool {
2481    s.is_empty()
2482        // Any Unicode whitespace at either edge, not just space and tab: a
2483        // leading U+00A0 gives the written line a non-zero indent (§3.3), so
2484        // the reader takes it for block content and the whole record vanishes.
2485        || s.starts_with(char::is_whitespace)
2486        || s.ends_with(char::is_whitespace)
2487        || s.contains(';')
2488        || s.starts_with('#')
2489        || s.starts_with("//")
2490        || s.starts_with('!')
2491        // A leading quote would make §7.1 step 2 read the part as quoted.
2492        || s.starts_with('"')
2493        || s.starts_with('\'')
2494        || match hint {
2495            // A typed column casts what it reads (§8.2), so an unquoted `N/A`
2496            // in an `int` column comes back as null with a `CastFailed`. §7.4
2497            // exempts quoted parts from casting, so quoting is what preserves
2498            // the string.
2499            Some(h) => cast_typed_checked(s, h) != Some(Value::String(s.to_string())),
2500            None => cast_inline(s) != Value::String(s.to_string()),
2501        }
2502}
2503
2504/// A quote character that does not occur in `s`, or `None` if both do.
2505fn pick_quote(s: &str) -> Option<char> {
2506    if !s.contains('"') {
2507        Some('"')
2508    } else if !s.contains('\'') {
2509        Some('\'')
2510    } else {
2511        None
2512    }
2513}
2514
2515/// §14.2 / §14.3 — one inline part.
2516fn write_inline(out: &mut String, value: &Value, hint: Option<&str>) {
2517    match value {
2518        // §14.2 — an unset field is an empty part.
2519        Value::Null => {}
2520        Value::Bool(b) => out.push_str(if *b { "true" } else { "false" }),
2521        Value::Int(n) => {
2522            let mut buf = itoa::Buffer::new();
2523            out.push_str(buf.format(*n));
2524        }
2525        Value::Float(f) => write_float(out, *f),
2526        Value::String(s) | Value::Secret(s) => {
2527            if inline_needs_quote(s, hint) {
2528                match pick_quote(s) {
2529                    Some(q) => {
2530                        out.push(q);
2531                        out.push_str(s);
2532                        out.push(q);
2533                    }
2534                    // Unreachable for a promoted column; emit the raw text
2535                    // rather than losing the value outright.
2536                    None => out.push_str(s),
2537                }
2538            } else {
2539                out.push_str(s);
2540            }
2541        }
2542        // Promoted by `needs_block`; nothing sensible to write here.
2543        Value::Object(_) | Value::Array(_) => {}
2544    }
2545}
2546
2547/// Write a float so that the SYNX cast reads it back as the same float.
2548///
2549/// SYNX §8.3 only recognises `-?digits.digits`, so shortest-form output such
2550/// as `1e300` or `5` would come back as a string or an int. Non-finite values
2551/// have no JSON form either (`write_json` emits `null` for them), so they are
2552/// written as an empty part — whose projection is the same `null`.
2553fn write_float(out: &mut String, f: f64) {
2554    if !f.is_finite() {
2555        return;
2556    }
2557    let s = f.to_string();
2558    if !s.contains('e') && !s.contains('E') {
2559        out.push_str(&s);
2560        if !s.contains('.') {
2561            out.push_str(".0");
2562        }
2563        return;
2564    }
2565    // Exponent form: spell the value out in full. f64 decimals terminate, so
2566    // the expansion is exact.
2567    let expanded = format!("{:.*}", FLOAT_EXPANSION_PRECISION, f);
2568    let trimmed = expanded.trim_end_matches('0');
2569    let trimmed = if trimmed.ends_with('.') { &expanded[..trimmed.len() + 1] } else { trimmed };
2570    out.push_str(trimmed);
2571}
2572
2573fn trim_float(f: f64) -> String {
2574    let s = f.to_string();
2575    s.strip_suffix(".0").map(|s| s.to_string()).unwrap_or(s)
2576}
2577
2578// ─── §14 Block serialization (SYNX subset) ───────────────────
2579
2580/// Write `key: value` as SYNX at `indent` columns.
2581fn write_synx_entry(out: &mut String, key: &str, value: &Value, indent: usize, depth: usize) {
2582    if depth > MAX_WRITE_DEPTH {
2583        return;
2584    }
2585    match value {
2586        Value::Object(map) => {
2587            push_indent(out, indent);
2588            out.push_str(key);
2589            out.push('\n');
2590            let mut keys: Vec<&String> = map.keys().collect();
2591            keys.sort_unstable();
2592            for k in keys {
2593                write_synx_entry(out, k, &map[k], indent + 2, depth + 1);
2594            }
2595        }
2596        Value::Array(items) => {
2597            push_indent(out, indent);
2598            out.push_str(key);
2599            if items.is_empty() {
2600                // A bare key parses back as an empty object; the `:join` list
2601                // marker is the only SYNX surface that yields an empty array.
2602                out.push_str(":join");
2603            }
2604            out.push('\n');
2605            for item in items {
2606                write_synx_item(out, item, indent + 2, depth + 1);
2607            }
2608        }
2609        Value::String(s) if s.contains('\n') => write_synx_multiline(out, key, s, indent),
2610        _ => {
2611            let scalar = synx_scalar(value);
2612            match scalar {
2613                Some(text) => {
2614                    push_indent(out, indent);
2615                    out.push_str(key);
2616                    out.push(' ');
2617                    out.push_str(&text);
2618                    out.push('\n');
2619                }
2620                // Not representable as a single SYNX token — fall back to an
2621                // indent-preserving block, whose body is never comment-stripped
2622                // nor cast.
2623                None => {
2624                    let s = value.as_str().unwrap_or_default().to_string();
2625                    write_synx_multiline(out, key, &s, indent);
2626                }
2627            }
2628        }
2629    }
2630}
2631
2632/// Write one `- item` list entry.
2633fn write_synx_item(out: &mut String, item: &Value, indent: usize, depth: usize) {
2634    if depth > MAX_WRITE_DEPTH {
2635        return;
2636    }
2637    match item {
2638        Value::Object(map) => {
2639            let mut keys: Vec<&String> = map.keys().collect();
2640            keys.sort_unstable();
2641            // The `- ` line has to carry a key whose value fits on it; SYNX
2642            // attaches everything deeper to the *item*, not to that key.
2643            let head = keys
2644                .iter()
2645                .position(|k| dash_line_safe(&map[*k]))
2646                .unwrap_or(0);
2647            // An empty object in a list has no key to hang on the `- ` line.
2648            // SYNX writes it as a bare dash, which is how it parsed in the
2649            // first place; indexing `keys` here would panic instead.
2650            let Some(head_key) = keys.get(head).copied() else {
2651                push_indent(out, indent);
2652                out.push_str("-\n");
2653                return;
2654            };
2655            push_indent(out, indent);
2656            out.push_str("- ");
2657            out.push_str(head_key);
2658            match synx_scalar(&map[head_key]) {
2659                Some(text) if !text.is_empty() => {
2660                    out.push(' ');
2661                    out.push_str(&text);
2662                }
2663                // An empty object is written as a bare key, which is exactly
2664                // how SYNX produced it in the first place.
2665                _ => {}
2666            }
2667            out.push('\n');
2668            for (i, k) in keys.iter().enumerate() {
2669                if i == head {
2670                    continue;
2671                }
2672                write_synx_entry(out, k, &map[*k], indent + 2, depth + 1);
2673            }
2674        }
2675        _ => {
2676            push_indent(out, indent);
2677            out.push_str("- ");
2678            match synx_scalar(item) {
2679                Some(text) => out.push_str(&text),
2680                None => out.push_str(item.as_str().unwrap_or_default()),
2681            }
2682            out.push('\n');
2683        }
2684    }
2685}
2686
2687/// May this value share the `- ` line of a list item?
2688fn dash_line_safe(value: &Value) -> bool {
2689    match value {
2690        Value::Object(map) => map.is_empty(),
2691        Value::Array(_) => false,
2692        Value::String(s) => !s.contains('\n') && synx_scalar(value).is_some() && !s.is_empty(),
2693        _ => true,
2694    }
2695}
2696
2697/// `key |+` plus an indented body (§14.3).
2698fn write_synx_multiline(out: &mut String, key: &str, body: &str, indent: usize) {
2699    push_indent(out, indent);
2700    out.push_str(key);
2701    out.push_str(" |+\n");
2702    for line in body.split('\n') {
2703        push_indent(out, indent + 2);
2704        out.push_str(line);
2705        out.push('\n');
2706    }
2707}
2708
2709/// Render a scalar as a SYNX value token, or `None` when no token can carry it.
2710///
2711/// A SYNX value is comment-stripped at ` #` / ` //` *before* casting, so a
2712/// value containing either sequence cannot be rescued by quoting.
2713fn synx_scalar(value: &Value) -> Option<String> {
2714    match value {
2715        Value::Null => Some("null".to_string()),
2716        Value::Bool(b) => Some(if *b { "true" } else { "false" }.to_string()),
2717        Value::Int(n) => Some(n.to_string()),
2718        Value::Float(f) => {
2719            // A non-finite float has no JSON form either — `write_json` emits
2720            // `null` for it, so `null` is the projection-preserving token.
2721            if !f.is_finite() {
2722                return Some("null".to_string());
2723            }
2724            let mut s = String::new();
2725            write_float(&mut s, *f);
2726            Some(s)
2727        }
2728        Value::String(s) | Value::Secret(s) => {
2729            if s.contains('\n') || s.contains(" #") || s.contains(" //") {
2730                return None;
2731            }
2732            if !synx_value_needs_quote(s) {
2733                return Some(s.clone());
2734            }
2735            pick_quote(s).map(|q| format!("{}{}{}", q, s, q))
2736        }
2737        Value::Object(_) | Value::Array(_) => None,
2738    }
2739}
2740
2741/// Would this string be misread as a bare SYNX value?
2742fn synx_value_needs_quote(s: &str) -> bool {
2743    s.is_empty()
2744        || s != s.trim()
2745        || s == "|"
2746        || s == "|+"
2747        || is_quote_wrapped(s)
2748        || !matches!(parser::cast(s), Value::String(ref v) if v == s)
2749}
2750
2751// ─── Small helpers ───────────────────────────────────────────
2752
2753#[inline]
2754fn starts_with_wsp(s: &str) -> bool {
2755    s.starts_with(' ') || s.starts_with('\t')
2756}
2757
2758#[inline]
2759fn trim_wsp(s: &str) -> &str {
2760    s.trim_matches(|c| c == ' ' || c == '\t')
2761}
2762
2763/// Does `s` both start and end with the same ASCII quote, length ≥ 2?
2764fn is_quote_wrapped(s: &str) -> bool {
2765    let b = s.as_bytes();
2766    b.len() >= 2
2767        && ((b[0] == b'"' && b[b.len() - 1] == b'"') || (b[0] == b'\'' && b[b.len() - 1] == b'\''))
2768}
2769
2770fn push_indent(out: &mut String, n: usize) {
2771    for _ in 0..n {
2772        out.push(' ');
2773    }
2774}
2775
2776/// Truncate to at most `max` bytes at a valid UTF-8 boundary (§13).
2777fn truncate_utf8(s: &str, max: usize) -> &str {
2778    if s.len() <= max {
2779        return s;
2780    }
2781    let mut end = max;
2782    while end > 0 && !s.is_char_boundary(end) {
2783        end -= 1;
2784    }
2785    &s[..end]
2786}
2787
2788/// Shorten a fragment for an error message.
2789fn elide(s: &str) -> String {
2790    const MAX: usize = 48;
2791    if s.len() <= MAX {
2792        return s.to_string();
2793    }
2794    let cut = truncate_utf8(s, MAX);
2795    format!("{}…", cut)
2796}
2797
2798#[cfg(test)]
2799mod tests {
2800    use super::*;
2801
2802    fn doc(src: &str) -> SynxlDocument {
2803        parse_lines(src).expect("expected a successful parse")
2804    }
2805
2806    fn kinds(d: &SynxlDocument) -> Vec<DiagnosticKind> {
2807        d.diagnostics.iter().map(|x| x.kind).collect()
2808    }
2809
2810    // ── §4 document structure ───────────────────────────────
2811
2812    #[test]
2813    fn parses_prologue_and_simple_records() {
2814        let d = doc("!synxl 1\n!fields id[type:int] ; name\n1 ; Wario\n2 ; Mario\n");
2815        assert_eq!(d.version, 1);
2816        assert_eq!(d.len(), 2);
2817        assert_eq!(d.to_json(), r#"[{"id":1,"name":"Wario"},{"id":2,"name":"Mario"}]"#);
2818        assert!(d.diagnostics.is_empty());
2819    }
2820
2821    #[test]
2822    fn bom_and_crlf_are_tolerated() {
2823        let d = doc("\u{feff}!synxl 1\r\n!fields a ; b\r\n1 ; 2\r\n");
2824        assert_eq!(d.to_json(), r#"[{"a":1,"b":2}]"#);
2825    }
2826
2827    #[test]
2828    fn comments_and_blank_lines_before_prologue() {
2829        let d = doc("# hi\n\n// there\n###\n!synxl 999\n###\n!synxl 1\n!fields a\nx\n");
2830        assert_eq!(d.to_json(), r#"[{"a":"x"}]"#);
2831    }
2832
2833    #[test]
2834    fn missing_prologue_is_a_hard_error() {
2835        let e = parse_lines("!fields a\nx\n").unwrap_err();
2836        assert_eq!(e.kind, SynxlErrorKind::MissingPrologue);
2837        let e = parse_lines("").unwrap_err();
2838        assert_eq!(e.kind, SynxlErrorKind::MissingPrologue);
2839        let e = parse_lines("!synxl\n!fields a\n").unwrap_err();
2840        assert_eq!(e.kind, SynxlErrorKind::MissingPrologue);
2841        // Trailing garbage after the version is not a prologue.
2842        let e = parse_lines("!synxl 1 extra\n!fields a\n").unwrap_err();
2843        assert_eq!(e.kind, SynxlErrorKind::MissingPrologue);
2844    }
2845
2846    #[test]
2847    fn unsupported_version_is_a_hard_error() {
2848        let e = parse_lines("!synxl 2\n!fields a\nx\n").unwrap_err();
2849        assert_eq!(e.kind, SynxlErrorKind::UnsupportedVersion);
2850        let e = parse_lines("!synxl 99999999999999999999\n!fields a\n").unwrap_err();
2851        assert_eq!(e.kind, SynxlErrorKind::UnsupportedVersion);
2852    }
2853
2854    #[test]
2855    fn record_without_field_list_is_a_hard_error() {
2856        let e = parse_lines("!synxl 1\nrogue record\n").unwrap_err();
2857        assert_eq!(e.kind, SynxlErrorKind::NoFieldList);
2858        assert_eq!(e.line, 2);
2859    }
2860
2861    #[test]
2862    fn field_list_can_be_redeclared_mid_document() {
2863        // §4.2 / §10 — records keep the schema they were parsed under.
2864        let src = "!synxl 1\n\
2865                   !fields id[type:int] ; score[type:float]\n\
2866                   1 ; 0.91\n\
2867                   # schema evolution\n\
2868                   !fields id[type:int] ; score[type:float] ; lang\n\
2869                   3 ; 0.55 ; ru\n";
2870        let d = doc(src);
2871        assert_eq!(
2872            d.to_json(),
2873            r#"[{"id":1,"score":0.91},{"id":3,"lang":"ru","score":0.55}]"#
2874        );
2875        assert_eq!(d.field_lists.len(), 2);
2876        assert_eq!(d.record_field_lists, vec![0, 1]);
2877        assert_eq!(d.field_list_for(1).unwrap().arity(), 3);
2878    }
2879
2880    // ── §5 field list ───────────────────────────────────────
2881
2882    #[test]
2883    fn duplicate_field_name_is_a_hard_error() {
2884        let e = parse_lines("!synxl 1\n!fields a ; b ; a\nx ; y ; z\n").unwrap_err();
2885        assert_eq!(e.kind, SynxlErrorKind::DuplicateField);
2886    }
2887
2888    #[test]
2889    fn marker_chain_is_a_hard_error() {
2890        let e = parse_lines("!synxl 1\n!fields port:env:default:3000\n1\n").unwrap_err();
2891        assert_eq!(e.kind, SynxlErrorKind::MarkerChain);
2892        let e = parse_lines("!synxl 1\n!fields id[required]:env\n1\n").unwrap_err();
2893        assert_eq!(e.kind, SynxlErrorKind::MarkerChain);
2894        // §5.2 — a single marker run is forbidden too, not just a chain.
2895        let e = parse_lines("!synxl 1\n!fields id:custom\n1\n").unwrap_err();
2896        assert_eq!(e.kind, SynxlErrorKind::MarkerChain);
2897    }
2898
2899    #[test]
2900    fn non_deterministic_hint_is_a_hard_error() {
2901        for src in [
2902            "!synxl 1\n!fields id(random)\n1\n",
2903            "!synxl 1\n!fields id(random:float)\n1\n",
2904            "!synxl 1\n!fields id[type:random:bool]\n1\n",
2905        ] {
2906            let e = parse_lines(src).unwrap_err();
2907            assert_eq!(e.kind, SynxlErrorKind::NonDeterministicHint, "{src}");
2908        }
2909    }
2910
2911    #[test]
2912    fn block_with_type_is_a_hard_error() {
2913        let e = parse_lines("!synxl 1\n!fields m(int)[block]\n\n").unwrap_err();
2914        assert_eq!(e.kind, SynxlErrorKind::BlockWithType);
2915        let e = parse_lines("!synxl 1\n!fields m[block, type:int]\n\n").unwrap_err();
2916        assert_eq!(e.kind, SynxlErrorKind::BlockWithType);
2917    }
2918
2919    #[test]
2920    fn malformed_field_lists_are_hard_errors() {
2921        for src in [
2922            "!synxl 1\n!fields\nx\n",
2923            "!synxl 1\n!fields a ; ; b\nx\n",
2924            "!synxl 1\n!fields a[required\nx\n",
2925            "!synxl 1\n!fields a(int\nx\n",
2926        ] {
2927            let e = parse_lines(src).unwrap_err();
2928            assert_eq!(e.kind, SynxlErrorKind::MalformedFieldList, "{src}");
2929        }
2930    }
2931
2932    #[test]
2933    fn field_name_length_limit() {
2934        let long = "n".repeat(MAX_SYNXL_FIELD_NAME_BYTES + 1);
2935        let e = parse_lines(&format!("!synxl 1\n!fields {long}\nx\n")).unwrap_err();
2936        assert_eq!(e.kind, SynxlErrorKind::LimitExceeded);
2937    }
2938
2939    #[test]
2940    fn field_count_limit() {
2941        let mut src = String::from("!synxl 1\n!fields ");
2942        for i in 0..(MAX_SYNXL_FIELDS + 1) {
2943            if i > 0 {
2944                src.push_str(" ; ");
2945            }
2946            src.push_str(&format!("f{i}"));
2947        }
2948        src.push('\n');
2949        let e = parse_lines(&src).unwrap_err();
2950        assert_eq!(e.kind, SynxlErrorKind::LimitExceeded);
2951    }
2952
2953    #[test]
2954    fn unrecognised_constraint_parts_are_tolerated() {
2955        // §5.2 — a version-1 parser stays readable for later producers.
2956        let d = doc("!synxl 1\n!fields a[required, futureflag, future:thing]\n5\n");
2957        assert_eq!(d.to_json(), r#"[{"a":5}]"#);
2958        assert!(d.field_lists[0].get("a").unwrap().constraints.required);
2959    }
2960
2961    #[test]
2962    fn arity_ignores_block_fields() {
2963        let d = doc("!synxl 1\n!fields a ; b ; m[block]\n1 ; 2\n");
2964        assert_eq!(d.field_lists[0].arity(), 2);
2965        assert!(d.diagnostics.is_empty());
2966        assert_eq!(d.to_json(), r#"[{"a":1,"b":2,"m":null}]"#);
2967    }
2968
2969    // ── §6 record lines ─────────────────────────────────────
2970
2971    #[test]
2972    fn synx_first_character_filter_does_not_apply() {
2973        let src = "!synxl 1\n!fields v\n-5\n/var/log/app\n@kaiserberg\n[unparsed]\n:marker\n(paren)\n";
2974        let d = doc(src);
2975        assert_eq!(
2976            d.to_json(),
2977            r#"[{"v":-5},{"v":"/var/log/app"},{"v":"@kaiserberg"},{"v":"[unparsed]"},{"v":":marker"},{"v":"(paren)"}]"#
2978        );
2979    }
2980
2981    #[test]
2982    fn reserved_prefixes_at_indent_zero() {
2983        // `//` is a comment, a single `/` is data, `#` is a comment, and a
2984        // record meant to start with a reserved prefix must quote it.
2985        let d = doc("!synxl 1\n!fields v\n//comment\n/data\n# comment\n\"!bang\"\n'#quoted'\n");
2986        assert_eq!(d.to_json(), r##"[{"v":"/data"},{"v":"!bang"},{"v":"#quoted"}]"##);
2987    }
2988
2989    #[test]
2990    fn unknown_bang_line_is_a_hard_error() {
2991        // §4.1 — the `!filds` typo must not silently leave the old schema in
2992        // effect; SYNX directives are rejected for the same reason.
2993        for src in [
2994            "!synxl 1\n!fields a\n1\n!filds a ; b\n2 ; 3\n",
2995            "!synxl 1\n!active\n!fields a\n1\n",
2996            "!synxl 1\n!fields a\n!include /etc/passwd\n1\n",
2997            "!synxl 1\n!fieldsx a\n1\n",
2998        ] {
2999            let e = parse_lines(src).unwrap_err();
3000            assert_eq!(e.kind, SynxlErrorKind::UnknownDirective, "{src}");
3001        }
3002    }
3003
3004    #[test]
3005    fn repeated_prologue() {
3006        // §4.1 — shards are concatenable when the version matches.
3007        let d = doc("!synxl 1\n!fields a\n1\n!synxl 1\n!fields a\n2\n");
3008        assert_eq!(d.to_json(), r#"[{"a":1},{"a":2}]"#);
3009        let e = parse_lines("!synxl 1\n!fields a\n1\n!synxl 2\n!fields a\n2\n").unwrap_err();
3010        assert_eq!(e.kind, SynxlErrorKind::UnsupportedVersion);
3011        assert_eq!(e.line, 4);
3012    }
3013
3014    #[test]
3015    fn zero_arity_field_list_is_a_hard_error() {
3016        // §5.3.4 — an all-block field list has no record line to write.
3017        let e = parse_lines("!synxl 1\n!fields m[block]\n\n  m\n    k v\n").unwrap_err();
3018        assert_eq!(e.kind, SynxlErrorKind::ZeroArity);
3019        let e = parse_lines("!synxl 1\n!fields a[block] ; b[block]\n").unwrap_err();
3020        assert_eq!(e.kind, SynxlErrorKind::ZeroArity);
3021    }
3022
3023    // ── §7 inline fields ────────────────────────────────────
3024
3025    #[test]
3026    fn quoting_and_the_unquoted_fallback() {
3027        let d = doc(
3028            "!synxl 1\n!fields a ; b ; c\n\
3029             \"has ; semi\" ; 'single' ; \"a\"b\" \n",
3030        );
3031        let rec = d.records[0].as_object().unwrap();
3032        assert_eq!(rec["a"], Value::String("has ; semi".into()));
3033        assert_eq!(rec["b"], Value::String("single".into()));
3034        // §7.1 step 3: trailing garbage after the close quote ⇒ unquoted, and
3035        // §8.1 must not strip the quotes a second time.
3036        assert_eq!(rec["c"], Value::String("\"a\"b\"".into()));
3037    }
3038
3039    #[test]
3040    fn unterminated_quote_falls_back_to_unquoted() {
3041        let d = doc("!synxl 1\n!fields a ; b\n\"open ; still\n");
3042        let rec = d.records[0].as_object().unwrap();
3043        assert_eq!(rec["a"], Value::String("\"open".into()));
3044        assert_eq!(rec["b"], Value::String("still".into()));
3045    }
3046
3047    #[test]
3048    fn quoted_values_bypass_casting() {
3049        let d = doc("!synxl 1\n!fields a ; b ; c\n\"42\" ; \"true\" ; \"null\"\n");
3050        assert_eq!(d.to_json(), r#"[{"a":"42","b":"true","c":"null"}]"#);
3051    }
3052
3053    #[test]
3054    fn null_versus_empty_string() {
3055        // §7.2 — the ambiguity CSV never resolved. Note the record line cannot
3056        // start with a space: indent > 0 would make it block content (§3.4).
3057        let d = doc("!synxl 1\n!fields a ; b ; c\n; \"\" ; x\n");
3058        assert_eq!(d.to_json(), r#"[{"a":null,"b":"","c":"x"}]"#);
3059    }
3060
3061    #[test]
3062    fn quoted_part_keeps_interior_whitespace() {
3063        let d = doc("!synxl 1\n!fields a ; b\n\"  padded  \"  ;   bare  \n");
3064        let rec = d.records[0].as_object().unwrap();
3065        assert_eq!(rec["a"], Value::String("  padded  ".into()));
3066        assert_eq!(rec["b"], Value::String("bare".into()));
3067    }
3068
3069    #[test]
3070    fn only_spaces_and_tabs_are_trimmed_from_a_part() {
3071        // §7.1 step 4 — Unicode whitespace is content, not padding. The NBSP
3072        // must not lead the *line*, though: §3.3 counts it as indentation
3073        // (SYNX §4 ltrims Unicode whitespace), which would make the line block
3074        // content rather than a record.
3075        let d = doc("!synxl 1\n!fields a ; b\nx ; \u{a0}hello\u{a0}\n");
3076        assert_eq!(
3077            d.records[0].as_object().unwrap()["b"],
3078            Value::String("\u{a0}hello\u{a0}".into())
3079        );
3080        // A leading NBSP is indentation, so this line is not a record at all.
3081        let d = doc("!synxl 1\n!fields a\n\u{a0}hello\n");
3082        assert_eq!(d.to_json(), "[]");
3083        assert_eq!(kinds(&d), vec![DiagnosticKind::OrphanBlockLine]);
3084    }
3085
3086    #[test]
3087    fn arity_mismatch_in_both_directions() {
3088        let d = doc("!synxl 1\n!fields a ; b ; c\n1 ; 2\n1 ; 2 ; 3 ; 4 ; 5\n");
3089        assert_eq!(kinds(&d), vec![DiagnosticKind::MissingFields, DiagnosticKind::ExtraFields]);
3090        assert_eq!(d.diagnostics[0].record_index, 0);
3091        assert_eq!(d.diagnostics[0].line, 3);
3092        assert_eq!(d.diagnostics[1].record_index, 1);
3093        assert_eq!(d.diagnostics[1].line, 4);
3094        assert_eq!(d.to_json(), r#"[{"a":1,"b":2,"c":null},{"a":1,"b":2,"c":3}]"#);
3095    }
3096
3097    #[test]
3098    fn all_null_record() {
3099        // §7.2 — `;` sets every inline field to null at any arity, silently.
3100        let d = doc("!synxl 1\n!fields a\n;\n");
3101        assert_eq!(d.to_json(), r#"[{"a":null}]"#);
3102        assert!(d.diagnostics.is_empty());
3103
3104        let d = doc("!synxl 1\n!fields a ; b\n;\n");
3105        assert_eq!(d.to_json(), r#"[{"a":null,"b":null}]"#);
3106        assert!(d.diagnostics.is_empty());
3107
3108        // Arity 3 would otherwise raise a spurious `MissingFields`.
3109        let d = doc("!synxl 1\n!fields a ; b ; c\n;\t\n");
3110        assert_eq!(d.to_json(), r#"[{"a":null,"b":null,"c":null}]"#);
3111        assert!(d.diagnostics.is_empty());
3112
3113        // Block fields are unaffected — the block is still parsed.
3114        let d = doc("!synxl 1\n!fields a ; m[block]\n;\n  m\n    k v\n");
3115        assert_eq!(d.to_json(), r#"[{"a":null,"m":{"k":"v"}}]"#);
3116        assert!(d.diagnostics.is_empty());
3117
3118        // Only the exact `;` form is special: `;;` is an ordinary line of three
3119        // empty parts and keeps the §7.1 / §7.3 treatment.
3120        let d = doc("!synxl 1\n!fields a ; b\n;;\n");
3121        assert_eq!(d.to_json(), r#"[{"a":null,"b":null}]"#);
3122        assert_eq!(kinds(&d), vec![DiagnosticKind::ExtraFields]);
3123    }
3124
3125    #[test]
3126    fn all_null_record_round_trips_at_every_arity() {
3127        // §7.2 — the writer MUST emit this form; §14.1 then holds at arity 1,
3128        // where no other representation exists.
3129        for fields in ["a", "a ; b", "a ; b ; c", "a ; b ; m[block]"] {
3130            let src = format!("!synxl 1\n!fields {fields}\n;\n");
3131            let a = doc(&src);
3132            let written = a.to_synxl().unwrap();
3133            assert!(
3134                written.lines().any(|l| l == ";"),
3135                "expected an all-null record line for `{fields}`:\n{written}"
3136            );
3137            assert_eq!(a.to_json(), doc(&written).to_json(), "{written}");
3138            assert!(doc(&written).diagnostics.is_empty(), "{written}");
3139        }
3140    }
3141
3142    #[test]
3143    fn trailing_semicolon_yields_a_part() {
3144        let d = doc("!synxl 1\n!fields a ; b\n1 ;\n");
3145        assert_eq!(d.to_json(), r#"[{"a":1,"b":null}]"#);
3146        assert!(d.diagnostics.is_empty());
3147    }
3148
3149    #[test]
3150    fn inline_comments_are_not_stripped() {
3151        // §7.5 — `#` and `//` are content in a dataset.
3152        let d = doc("!synxl 1\n!fields text\nsee https://x.dev #hashtag // not a comment\n");
3153        assert_eq!(
3154            d.records[0].as_object().unwrap()["text"],
3155            Value::String("see https://x.dev #hashtag // not a comment".into())
3156        );
3157    }
3158
3159    // ── §8 casting ──────────────────────────────────────────
3160
3161    #[test]
3162    fn automatic_casting() {
3163        let d = doc("!synxl 1\n!fields a ; b ; c ; d ; e ; f\n1 ; -2.5 ; true ; false ; null ; text\n");
3164        assert_eq!(
3165            d.to_json(),
3166            r#"[{"a":1,"b":-2.5,"c":true,"d":false,"e":null,"f":"text"}]"#
3167        );
3168    }
3169
3170    #[test]
3171    fn typed_cast_failure_nulls_the_cell_only() {
3172        let d = doc("!synxl 1\n!fields id[type:int] ; score(float)\nnope ; 0.5\n7 ; nope\n");
3173        assert_eq!(kinds(&d), vec![DiagnosticKind::CastFailed, DiagnosticKind::CastFailed]);
3174        assert_eq!(d.to_json(), r#"[{"id":null,"score":0.5},{"id":7,"score":null}]"#);
3175    }
3176
3177    #[test]
3178    fn typed_bool_and_string() {
3179        let d = doc("!synxl 1\n!fields a[type:bool] ; b(string) ; c[type:bool]\ntrue ; 42 ; yes\n");
3180        assert_eq!(d.to_json(), r#"[{"a":true,"b":"42","c":null}]"#);
3181        assert_eq!(kinds(&d), vec![DiagnosticKind::CastFailed]);
3182    }
3183
3184    #[test]
3185    fn validation_is_opt_in() {
3186        let src = "!synxl 1\n!fields id[type:int, min:10] ; name[required]\n5 ; \n";
3187        let d = doc(src);
3188        assert!(d.diagnostics.is_empty());
3189
3190        let d = parse_lines_with(src, &SynxlOptions { validate: true }).unwrap();
3191        assert_eq!(
3192            kinds(&d),
3193            vec![DiagnosticKind::ConstraintViolation, DiagnosticKind::ConstraintViolation]
3194        );
3195    }
3196
3197    // ── §9 blocks ───────────────────────────────────────────
3198
3199    #[test]
3200    fn worked_example_from_the_spec() {
3201        // NB: a plain (non-continued) literal — a `\` line continuation would
3202        // strip the leading whitespace that makes these lines a block.
3203        let src = "!synxl 1
3204!fields id[type:int, required] ; score[type:float] ; messages[block]
3205
32061 ; 0.91
3207  messages
3208    - role system
3209      content You are a helpful assistant.
3210    - role user
3211      content |+
3212          def f(x):
3213              return x + 1
3214
32152 ; 0.74
3216  messages
3217    - role user
3218      content Привет
3219";
3220        let d = doc(src);
3221        assert_eq!(d.len(), 2);
3222        assert!(d.diagnostics.is_empty());
3223        let mut expected = String::from(
3224            r#"{"id":1,"messages":[{"content":"You are a helpful assistant.","role":"system"},"#,
3225        );
3226        expected.push_str(r#"{"content":"def f(x):\n    return x + 1","role":"user"}],"score":0.91}"#);
3227        let mut json = String::new();
3228        crate::write_json(&mut json, &d.records[0]);
3229        assert_eq!(json, expected);
3230        assert_eq!(
3231            d.to_ndjson().lines().count(),
3232            2,
3233            "NDJSON projection is one object per line"
3234        );
3235    }
3236
3237    #[test]
3238    fn empty_block_yields_null_block_fields() {
3239        let d = doc("!synxl 1\n!fields a ; m[block]\n1\n\n2\n");
3240        assert_eq!(d.to_json(), r#"[{"a":1,"m":null},{"a":2,"m":null}]"#);
3241    }
3242
3243    #[test]
3244    fn blank_lines_do_not_terminate_a_block() {
3245        let d = doc("!synxl 1\n!fields a ; m[block]\n1\n\n  m\n    x 1\n\n2\n");
3246        assert_eq!(d.to_json(), r#"[{"a":1,"m":{"x":1}},{"a":2,"m":null}]"#);
3247    }
3248
3249    #[test]
3250    fn block_key_diagnostics() {
3251        let d = doc("!synxl 1\n!fields a ; m[block]\n1\n  bogus 1\n  a 99\n");
3252        // §9.3 — sorted key order keeps this deterministic despite the HashMap.
3253        assert_eq!(
3254            kinds(&d),
3255            vec![DiagnosticKind::BlockFieldNotDeclared, DiagnosticKind::UnknownBlockKey]
3256        );
3257        // §11.2 — these two report the *block* line carrying the key, not the
3258        // record line: `a` is on line 5, `bogus` on line 4.
3259        assert_eq!(d.diagnostics[0].line, 5);
3260        assert_eq!(d.diagnostics[1].line, 4);
3261        assert!(d.diagnostics.iter().all(|x| x.record_index == 0));
3262        // The inline value stays authoritative.
3263        assert_eq!(d.to_json(), r#"[{"a":1,"m":null}]"#);
3264    }
3265
3266    #[test]
3267    fn block_diagnostic_line_survives_nesting_and_multiline() {
3268        let src = "!synxl 1
3269!fields id ; m[block]
32701
3271  m
3272    - role user
3273      content |+
3274        text
3275  zzz here
3276";
3277        let d = doc(src);
3278        assert_eq!(kinds(&d), vec![DiagnosticKind::UnknownBlockKey]);
3279        // `zzz` is the only unmatched top-level key, on line 8.
3280        assert_eq!(d.diagnostics[0].line, 8);
3281    }
3282
3283    #[test]
3284    fn type_hints_inside_a_cell_are_not_interpreted() {
3285        // §8.3 — casting is driven exclusively by the field list.
3286        let d = doc("!synxl 1\n!fields a ; b\n(random) ; (int)5\n");
3287        assert_eq!(d.to_json(), r#"[{"a":"(random)","b":"(int)5"}]"#);
3288    }
3289
3290    #[test]
3291    fn directives_inside_a_block_are_ignored() {
3292        // §9.4 — outside a multiline block a `!` line is discarded entirely.
3293        let src = "!synxl 1\n!fields a ; m[block]\n1\n  m\n    !include /etc/passwd\n    k v\n";
3294        let d = doc(src);
3295        assert_eq!(d.to_json(), r#"[{"a":1,"m":{"k":"v"}}]"#);
3296    }
3297
3298    #[test]
3299    fn directives_inside_a_multiline_body_are_preserved() {
3300        // §9.4 — inside `|+` the very same line is data.
3301        let src = "!synxl 1\n!fields a ; m[block]\n1\n  m |+\n    !include /etc/passwd\n    !active\n";
3302        let d = doc(src);
3303        assert_eq!(
3304            d.records[0].as_object().unwrap()["m"],
3305            Value::String("!include /etc/passwd\n!active".into())
3306        );
3307    }
3308
3309    #[test]
3310    fn active_mode_cannot_be_switched_on_from_a_block() {
3311        // §9.5 — no metadata surface, no marker resolution.
3312        let src = "!synxl 1\n!fields a ; m[block]\n1\n  !active\n  m\n    tax:calc 2 * 2\n";
3313        let d = doc(src);
3314        assert_eq!(d.to_json(), r#"[{"a":1,"m":{"tax":"2 * 2"}}]"#);
3315    }
3316
3317    #[test]
3318    fn orphan_block_lines_are_reported_against_the_next_record() {
3319        // §11.2 — discarded, but never silently.
3320        let d = doc("!synxl 1\n!fields a\n  stray one\n5\n!fields a\n  stray two\n");
3321        assert_eq!(d.to_json(), r#"[{"a":5}]"#);
3322        assert_eq!(
3323            kinds(&d),
3324            vec![DiagnosticKind::OrphanBlockLine, DiagnosticKind::OrphanBlockLine]
3325        );
3326        // Line 3 belongs to record 0, which has not been read yet; line 6 has
3327        // no following record and is attached to the index one past the end.
3328        assert_eq!((d.diagnostics[0].record_index, d.diagnostics[0].line), (0, 3));
3329        assert_eq!((d.diagnostics[1].record_index, d.diagnostics[1].line), (1, 6));
3330    }
3331
3332    // ── §13 limits ──────────────────────────────────────────
3333
3334    #[test]
3335    fn oversized_record_is_truncated_not_rejected() {
3336        let mut src = String::from("!synxl 1\n!fields a ; b\n");
3337        src.push_str("head ; ");
3338        src.push_str(&"x".repeat(MAX_SYNXL_RECORD_BYTES + 16));
3339        src.push_str("\ntail ; ok\n");
3340        let d = doc(&src);
3341        assert_eq!(d.len(), 2, "the following record still parses");
3342        assert_eq!(kinds(&d), vec![DiagnosticKind::RecordTruncated]);
3343        assert_eq!(d.diagnostics[0].record_index, 0);
3344        let b = d.records[0].as_object().unwrap()["b"].as_str().unwrap();
3345        assert!(b.len() < MAX_SYNXL_RECORD_BYTES);
3346        assert_eq!(d.records[1].as_object().unwrap()["a"], Value::String("tail".into()));
3347    }
3348
3349    #[test]
3350    fn field_list_count_limit() {
3351        let mut src = String::from("!synxl 1\n");
3352        for _ in 0..(MAX_SYNXL_FIELD_LISTS + 1) {
3353            src.push_str("!fields a\n");
3354        }
3355        let e = parse_lines(&src).unwrap_err();
3356        assert_eq!(e.kind, SynxlErrorKind::LimitExceeded);
3357    }
3358
3359    #[test]
3360    fn truncation_respects_utf8_boundaries() {
3361        // §13 — "truncated at a valid UTF-8 boundary".
3362        let s = "aя";
3363        assert_eq!(truncate_utf8(s, 2), "a");
3364        assert_eq!(truncate_utf8(s, 3), "aя");
3365        assert_eq!(truncate_utf8(s, 99), "aя");
3366        assert_eq!(truncate_utf8("😀", 3), "");
3367    }
3368
3369    #[test]
3370    fn streaming_and_whole_document_agree() {
3371        let src = "!synxl 1
3372!fields id[type:int] ; note ; m[block]
33731 ; ok
3374  m
3375    k v
33762 ; oops ; surplus
3377!fields id[type:int] ; note
33783
3379";
3380        let whole = doc(src);
3381        let mut records = Vec::new();
3382        let mut diagnostics = Vec::new();
3383        for item in SynxlReader::new(src).unwrap() {
3384            let mut rec = item.unwrap();
3385            records.push(rec.value);
3386            diagnostics.append(&mut rec.diagnostics);
3387        }
3388        assert_eq!(whole.to_json(), records_to_json_array(&records));
3389        assert_eq!(whole.diagnostics, diagnostics);
3390        assert_eq!(
3391            kinds(&whole),
3392            vec![DiagnosticKind::ExtraFields, DiagnosticKind::MissingFields]
3393        );
3394    }
3395
3396    #[test]
3397    fn a_document_may_exceed_the_synx_input_cap() {
3398        // §13 — the SYNX 16 MiB whole-input cap MUST NOT apply to SYNXL.
3399        let mut src = String::from("!synxl 1\n!fields a ; b\n");
3400        let row = "0123456789 ; abcdefghij\n";
3401        let rows = (17 * 1024 * 1024) / row.len() + 1;
3402        src.reserve(rows * row.len());
3403        for _ in 0..rows {
3404            src.push_str(row);
3405        }
3406        assert!(src.len() > 16 * 1024 * 1024);
3407        let d = doc(&src);
3408        assert_eq!(d.len(), rows);
3409        assert!(d.diagnostics.is_empty());
3410    }
3411
3412    // ── §15.1 streaming ─────────────────────────────────────
3413
3414    #[test]
3415    fn streaming_reader_yields_records_incrementally() {
3416        let src = "!synxl 1\n!fields a ; m[block]\n1\n  m\n    k v\n2 ; ignored\n";
3417        let mut reader = SynxlReader::new(src).unwrap();
3418        let first = reader.next().unwrap().unwrap();
3419        assert_eq!(first.index, 0);
3420        assert_eq!(first.line, 3);
3421        assert!(first.diagnostics.is_empty());
3422        let second = reader.next().unwrap().unwrap();
3423        assert_eq!(second.index, 1);
3424        assert_eq!(second.line, 6);
3425        assert_eq!(second.diagnostics.len(), 1);
3426        assert_eq!(second.diagnostics[0].kind, DiagnosticKind::ExtraFields);
3427        assert!(reader.next().is_none());
3428    }
3429
3430    #[test]
3431    fn owned_reader_matches_the_borrowing_one() {
3432        let src = "\u{feff}!synxl 1\r\n!fields id[type:int] ; note ; m[block]\r\n1 ; ok\r\n  m\r\n    k v\r\n2 ; oops ; surplus\r\n  bogus 1\r\n";
3433
3434        let borrowed: Vec<SynxlRecord> = SynxlReader::new(src)
3435            .unwrap()
3436            .map(Result::unwrap)
3437            .collect();
3438
3439        // The reader owns the text, so it can outlive the value it was built
3440        // from — no `unsafe`, no keeping the source alive by hand.
3441        let mut owned = {
3442            let moved = String::from(src);
3443            SynxlReaderOwned::new(moved).unwrap()
3444        };
3445        assert_eq!(owned.version(), 1);
3446        let mut collected = Vec::new();
3447        while let Some(item) = owned.next() {
3448            collected.push(item.unwrap());
3449        }
3450
3451        assert_eq!(collected, borrowed);
3452        assert_eq!(collected.len(), 2);
3453        assert_eq!(collected[1].line, 6, "BOM and CRLF must not shift line numbers");
3454        assert_eq!(owned.field_lists().len(), 1);
3455        assert_eq!(owned.text(), src);
3456    }
3457
3458    #[test]
3459    fn owned_reader_is_returnable_from_a_function() {
3460        fn open(src: &str) -> SynxlReaderOwned {
3461            SynxlReaderOwned::with_options(src.to_string(), SynxlOptions { validate: true })
3462                .unwrap()
3463        }
3464        let mut reader = open("!synxl 1\n!fields a[required]\n;\n");
3465        let rec = reader.next().unwrap().unwrap();
3466        assert_eq!(rec.diagnostics.len(), 1);
3467        assert_eq!(rec.diagnostics[0].kind, DiagnosticKind::ConstraintViolation);
3468        assert!(reader.next().is_none());
3469        assert_eq!(reader.into_text().lines().count(), 3);
3470    }
3471
3472    #[test]
3473    fn owned_reader_reports_the_prologue_error_eagerly() {
3474        let err = SynxlReaderOwned::new("nope\n".to_string()).unwrap_err();
3475        assert_eq!(err.kind, SynxlErrorKind::MissingPrologue);
3476    }
3477
3478    // ── §15.1 streaming from io::BufRead ────────────────────
3479
3480    /// Read a document three ways — borrowed, owned, and off a `BufRead` — and
3481    /// require identical records and diagnostics from all three.
3482    fn all_three_agree(src: &str) -> Vec<SynxlRecord> {
3483        let borrowed: Vec<SynxlRecord> =
3484            SynxlReader::new(src).unwrap().map(Result::unwrap).collect();
3485        let owned: Vec<SynxlRecord> = SynxlReaderOwned::new(src.to_string())
3486            .unwrap()
3487            .map(Result::unwrap)
3488            .collect();
3489        let streamed: Vec<SynxlRecord> = SynxlStreamReader::new(std::io::Cursor::new(src))
3490            .unwrap()
3491            .map(Result::unwrap)
3492            .collect();
3493        assert_eq!(owned, borrowed, "owned reader diverged");
3494        assert_eq!(streamed, borrowed, "io reader diverged");
3495        borrowed
3496    }
3497
3498    #[test]
3499    fn io_streaming_matches_the_in_memory_readers() {
3500        let recs = all_three_agree(
3501            "\u{feff}!synxl 1\r
3502!fields id[type:int] ; note ; m[block]\r
3503\r
35041 ; ok\r
3505  m\r
3506    - role user\r
3507      content |+\r
3508        line one\r
3509\r
3510        line two\r
35112 ; oops ; surplus\r
3512  bogus 1\r
3513!fields id[type:int]\r
3514  orphan\r
3515;\r
3516",
3517        );
3518        assert_eq!(recs.len(), 3);
3519        assert_eq!(recs[0].line, 4, "BOM and CRLF must not shift line numbers");
3520        assert_eq!(recs[1].diagnostics.len(), 2);
3521        assert_eq!(recs[2].value.as_object().unwrap()["id"], Value::Null);
3522    }
3523
3524    #[test]
3525    fn io_streaming_agrees_on_blocks_comments_and_diagnostics() {
3526        all_three_agree(
3527            "!synxl 1
3528###
3529!fields hidden
3530###
3531!fields a ; b ; m[block]
3532# comment
35331 ; 2
3534  m
3535    k v
3536
3537    j w
3538//comment
35393 ; 4 ; 5
3540  m
3541    !include /etc/passwd
3542    q |+
3543      !active
3544;
3545",
3546        );
3547    }
3548
3549    #[test]
3550    fn io_streaming_bounds_memory_on_an_oversized_record() {
3551        // §13 — the per-record cap is the real memory bound for a stream, and
3552        // it must cut at the same offset the in-memory readers cut at.
3553        let mut src = String::from("!synxl 1\n!fields a ; b\n");
3554        src.push_str("head ; ");
3555        src.push_str(&"x".repeat(MAX_SYNXL_RECORD_BYTES + 4096));
3556        src.push_str("\ntail ; ok\n");
3557
3558        let streamed: Vec<SynxlRecord> = SynxlStreamReader::new(std::io::Cursor::new(&src))
3559            .unwrap()
3560            .map(Result::unwrap)
3561            .collect();
3562        let borrowed: Vec<SynxlRecord> =
3563            SynxlReader::new(&src).unwrap().map(Result::unwrap).collect();
3564
3565        assert_eq!(streamed.len(), 2, "the record after the oversized one survives");
3566        assert_eq!(streamed[0].diagnostics[0].kind, DiagnosticKind::RecordTruncated);
3567        assert_eq!(
3568            streamed[0].value.as_object().unwrap()["b"],
3569            borrowed[0].value.as_object().unwrap()["b"],
3570            "both readers must cut at the same byte"
3571        );
3572        assert_eq!(streamed[1].value, borrowed[1].value);
3573    }
3574
3575    #[test]
3576    fn io_streaming_bounds_memory_on_an_oversized_block() {
3577        let mut src = String::from("!synxl 1\n!fields a ; m[block]\n1\n  m |+\n");
3578        let line = format!("    {}\n", "y".repeat(4095));
3579        for _ in 0..((MAX_SYNXL_RECORD_BYTES / line.len()) + 2) {
3580            src.push_str(&line);
3581        }
3582        src.push_str("2\n");
3583
3584        let streamed: Vec<SynxlRecord> = SynxlStreamReader::new(std::io::Cursor::new(&src))
3585            .unwrap()
3586            .map(Result::unwrap)
3587            .collect();
3588        let borrowed: Vec<SynxlRecord> =
3589            SynxlReader::new(&src).unwrap().map(Result::unwrap).collect();
3590
3591        assert_eq!(streamed.len(), 2);
3592        assert_eq!(streamed[0].diagnostics[0].kind, DiagnosticKind::RecordTruncated);
3593        assert_eq!(streamed[0].value, borrowed[0].value);
3594        assert_eq!(streamed[1].value, borrowed[1].value);
3595    }
3596
3597    #[test]
3598    fn io_streaming_survives_a_line_without_a_newline() {
3599        // A hostile document with no `LF` at all cannot be allowed to grow the
3600        // line buffer without bound; the cap applies to a single line too.
3601        let mut src = String::from("!synxl 1\n!fields a\n");
3602        src.push_str(&"z".repeat(MAX_SYNXL_RECORD_BYTES + 1024));
3603        let recs: Vec<SynxlRecord> = SynxlStreamReader::new(std::io::Cursor::new(&src))
3604            .unwrap()
3605            .map(Result::unwrap)
3606            .collect();
3607        assert_eq!(recs.len(), 1);
3608        assert_eq!(recs[0].diagnostics[0].kind, DiagnosticKind::RecordTruncated);
3609        let a = recs[0].value.as_object().unwrap()["a"].as_str().unwrap();
3610        assert_eq!(a.len(), MAX_SYNXL_RECORD_BYTES);
3611    }
3612
3613    #[test]
3614    fn io_streaming_separates_format_errors_from_io_errors() {
3615        // Format condition.
3616        let err = SynxlStreamReader::new(std::io::Cursor::new("nope\n")).unwrap_err();
3617        assert_eq!(err.as_format().unwrap().kind, SynxlErrorKind::MissingPrologue);
3618        assert!(err.as_io().is_none());
3619
3620        let mut reader =
3621            SynxlStreamReader::new(std::io::Cursor::new("!synxl 1\n!fields a\n1\n!filds a\n2\n"))
3622                .unwrap();
3623        assert!(reader.next().unwrap().is_ok());
3624        let err = reader.next().unwrap().unwrap_err();
3625        assert_eq!(err.as_format().unwrap().kind, SynxlErrorKind::UnknownDirective);
3626        assert!(reader.next().is_none(), "iteration stops after a hard error");
3627
3628        // I/O condition — invalid UTF-8 is not a format verdict (§3.1).
3629        let bad = [b'!', b's', b'y', b'n', b'x', b'l', b' ', b'1', b'\n', 0xff, 0xfe, b'\n'];
3630        let mut reader = SynxlStreamReader::new(std::io::Cursor::new(&bad[..])).unwrap();
3631        let err = reader.next().unwrap().unwrap_err();
3632        assert!(err.as_format().is_none());
3633        assert_eq!(err.as_io().unwrap().kind(), std::io::ErrorKind::InvalidData);
3634    }
3635
3636    #[test]
3637    fn io_streaming_reports_a_failing_source() {
3638        #[derive(Debug)]
3639        struct Boom;
3640        impl std::io::Read for Boom {
3641            fn read(&mut self, _: &mut [u8]) -> std::io::Result<usize> {
3642                Err(std::io::Error::new(std::io::ErrorKind::BrokenPipe, "boom"))
3643            }
3644        }
3645        let err = SynxlStreamReader::new(std::io::BufReader::new(Boom)).unwrap_err();
3646        assert_eq!(err.as_io().unwrap().kind(), std::io::ErrorKind::BrokenPipe);
3647    }
3648
3649    // ── §15.3 field-list source ─────────────────────────────
3650
3651    #[test]
3652    fn field_list_keeps_its_source_line() {
3653        // A splitting tool must re-emit the field list in effect verbatim.
3654        let d = doc("!synxl 1\n!fields  id[type:int]  ;  name \n1 ; x\n!fields a\n2\n");
3655        assert_eq!(d.field_lists[0].source(), "!fields  id[type:int]  ;  name");
3656        assert_eq!(d.field_lists[0].line, 2);
3657        assert_eq!(d.field_lists[1].source(), "!fields a");
3658        assert_eq!(d.field_lists[1].line, 4);
3659        // Programmatically built lists have none.
3660        assert_eq!(FieldList::new(vec![FieldDecl::new("a")]).source(), "");
3661
3662        // The same text reaches a streaming consumer, which is where §15.3
3663        // shard emission actually happens.
3664        let mut reader = SynxlReader::new("!synxl 1\n!fields  a ; b\n1 ; 2\n").unwrap();
3665        reader.next();
3666        assert_eq!(reader.field_list().unwrap().source(), "!fields  a ; b");
3667    }
3668
3669    #[test]
3670    fn streaming_reader_reports_a_mid_document_hard_error_once() {
3671        let src = "!synxl 1\n!fields a\n1\n!fields a ; a\n2\n";
3672        let mut reader = SynxlReader::new(src).unwrap();
3673        assert!(reader.next().unwrap().is_ok());
3674        let err = reader.next().unwrap().unwrap_err();
3675        assert_eq!(err.kind, SynxlErrorKind::DuplicateField);
3676        assert!(reader.next().is_none(), "iteration stops after a hard error");
3677    }
3678
3679    // ── §14 writer ──────────────────────────────────────────
3680
3681    fn round_trip(src: &str) {
3682        let a = doc(src);
3683        let written = a.to_synxl().unwrap();
3684        let b = doc(&written);
3685        assert_eq!(
3686            a.to_json(),
3687            b.to_json(),
3688            "round-trip mismatch\n--- written ---\n{written}"
3689        );
3690    }
3691
3692    #[test]
3693    fn round_trip_scalars_and_quoting() {
3694        round_trip(
3695            "!synxl 1\n!fields a ; b ; c ; d ; e ; f ; g\n\
3696             1 ; \"42\" ; \"has ; semi\" ; ; \"\" ; -2.5 ; true\n",
3697        );
3698    }
3699
3700    #[test]
3701    fn round_trip_reserved_prefixes_and_padding() {
3702        round_trip(
3703            "!synxl 1\n!fields a ; b ; c ; d\n\
3704             \"#tag\" ; \"//path\" ; \"!bang\" ; \"  padded  \"\n",
3705        );
3706    }
3707
3708    #[test]
3709    fn round_trip_blocks_and_schema_evolution() {
3710        round_trip(
3711            "!synxl 1
3712!fields id[type:int, required] ; score[type:float] ; messages[block]
37131 ; 0.91
3714  messages
3715    - role system
3716      content You are a helpful assistant.
3717    - role user
3718      content |+
3719          def f(x):
3720              return x + 1
3721!fields id[type:int] ; lang ; messages[block]
37223 ; ru
3723  messages
3724    - role user
3725      content Как дела?
3726",
3727        );
3728    }
3729
3730    #[test]
3731    fn writer_promotes_multiline_values_to_a_block() {
3732        // §14.3 — an LF forces promotion even though the field is inline.
3733        let fields = vec![FieldDecl::new("id"), FieldDecl::new("text")];
3734        let mut rec = HashMap::new();
3735        rec.insert("id".to_string(), Value::Int(1));
3736        rec.insert("text".to_string(), Value::String("line one\n  line two".into()));
3737        let records = vec![Value::Object(rec)];
3738        let out = write_lines(&fields, &records).unwrap();
3739        assert!(out.contains("text[block]"), "{out}");
3740        assert!(out.contains("|+"), "{out}");
3741        let back = doc(&out);
3742        assert_eq!(back.to_json(), records_to_json_array(&records));
3743    }
3744
3745    #[test]
3746    fn writer_promotes_values_that_cannot_be_quoted() {
3747        // A value with a `;` and both quote characters has no inline form.
3748        let fields = vec![FieldDecl::new("id"), FieldDecl::new("a")];
3749        let mut rec = HashMap::new();
3750        rec.insert("id".to_string(), Value::Int(1));
3751        rec.insert("a".to_string(), Value::String("mix \" and ' and ; here".into()));
3752        let records = vec![Value::Object(rec)];
3753        let out = write_lines(&fields, &records).unwrap();
3754        assert!(out.contains("a[block]"), "{out}");
3755        let back = doc(&out);
3756        assert_eq!(back.to_json(), records_to_json_array(&records));
3757    }
3758
3759    #[test]
3760    fn writer_rejects_an_unwritable_value() {
3761        // §14.3 — the value needs quoting, holds both quote characters (so no
3762        // quoting is available) and a `;` (so it cannot fall back to a bare
3763        // part either). Promoting it is the only option, and promoting the one
3764        // and only column would leave arity 0 — which §5.3.4 rejects. The
3765        // writer must say so, not emit a document its own parser refuses.
3766        let fields = vec![FieldDecl::new("a")];
3767        let mut rec = HashMap::new();
3768        rec.insert("a".to_string(), Value::String("mix \" and ' and ; here".into()));
3769        let records = vec![Value::Object(rec)];
3770
3771        let err = write_lines(&fields, &records).unwrap_err();
3772        assert_eq!(err.kind, SynxlErrorKind::Unwritable);
3773
3774        // Same value through a document.
3775        let doc = SynxlDocument {
3776            version: 1,
3777            records,
3778            field_lists: vec![FieldList::new(fields)],
3779            record_field_lists: vec![0],
3780            record_lines: vec![3],
3781            diagnostics: Vec::new(),
3782        };
3783        assert_eq!(doc.to_synxl().unwrap_err().kind, SynxlErrorKind::Unwritable);
3784    }
3785
3786    #[test]
3787    fn writer_keeps_one_column_inline_instead_of_zero_arity() {
3788        // §5.3.4 — promoting every column would produce an unparsable document,
3789        // so the last inline column stays inline even when it wants quoting.
3790        let fields = vec![FieldDecl::new("a")];
3791        let mut rec = HashMap::new();
3792        rec.insert("a".to_string(), Value::String("'x\"y".into()));
3793        let records = vec![Value::Object(rec)];
3794        let out = write_lines(&fields, &records).unwrap();
3795        assert!(!out.contains("[block]"), "{out}");
3796        let back = doc(&out);
3797        assert_eq!(back.to_json(), records_to_json_array(&records));
3798    }
3799
3800    #[test]
3801    fn writer_output_is_a_valid_document_without_records() {
3802        let d = doc("!synxl 1\n!fields a ; b\n");
3803        assert_eq!(d.to_synxl().unwrap(), "!synxl 1\n!fields a; b\n");
3804        assert_eq!(doc(&d.to_synxl().unwrap()).to_json(), "[]");
3805    }
3806
3807    #[test]
3808    fn write_lines_emits_the_canonical_shape() {
3809        let d = doc("!synxl 1\n!fields id[type:int] ; name\n1 ; Wario\n");
3810        let out = d.to_synxl().unwrap();
3811        assert_eq!(out, "!synxl 1\n!fields id[type:int]; name\n1; Wario\n");
3812    }
3813
3814    #[test]
3815    fn float_round_trips_through_exponent_form() {
3816        let fields = vec![FieldDecl::new("a"), FieldDecl::new("b"), FieldDecl::new("c")];
3817        let mut rec = HashMap::new();
3818        rec.insert("a".to_string(), Value::Float(1e300));
3819        rec.insert("b".to_string(), Value::Float(5.0));
3820        rec.insert("c".to_string(), Value::Float(f64::NAN));
3821        let records = vec![Value::Object(rec)];
3822        let out = write_lines(&fields, &records).unwrap();
3823        let back = doc(&out);
3824        assert_eq!(back.to_json(), records_to_json_array(&records));
3825    }
3826
3827    // ── §12 projections ─────────────────────────────────────
3828
3829    #[test]
3830    fn ndjson_projection() {
3831        let d = doc("!synxl 1\n!fields a ; b\n1 ; x\n2 ; y\n");
3832        assert_eq!(d.to_ndjson(), "{\"a\":1,\"b\":\"x\"}\n{\"a\":2,\"b\":\"y\"}\n");
3833    }
3834
3835    #[test]
3836    fn keys_are_sorted_in_the_projection() {
3837        let d = doc("!synxl 1\n!fields z ; a ; m\n1 ; 2 ; 3\n");
3838        assert_eq!(d.to_json(), r#"[{"a":2,"m":3,"z":1}]"#);
3839    }
3840
3841    // ── splitter unit coverage ──────────────────────────────
3842
3843    #[test]
3844    fn splitter_edge_cases() {
3845        let parts: Vec<Part> = PartSplitter::new("a;b").collect();
3846        assert_eq!(parts.len(), 3 - 1);
3847        let parts: Vec<Part> = PartSplitter::new("").collect();
3848        assert_eq!(parts, vec![Part { text: "", quoted: false }]);
3849        let parts: Vec<Part> = PartSplitter::new("'x' ; \"y\"").collect();
3850        assert_eq!(
3851            parts,
3852            vec![
3853                Part { text: "x", quoted: true },
3854                Part { text: "y", quoted: true }
3855            ]
3856        );
3857        // Close quote followed by garbage ⇒ unquoted, garbage included.
3858        let parts: Vec<Part> = PartSplitter::new("'x'y ; z").collect();
3859        assert_eq!(parts[0], Part { text: "'x'y", quoted: false });
3860        // A `;` inside quotes is not a delimiter.
3861        let parts: Vec<Part> = PartSplitter::new("\"a;b\"").collect();
3862        assert_eq!(parts, vec![Part { text: "a;b", quoted: true }]);
3863    }
3864}